diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 0ecf933..06589d9 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -21,6 +21,8 @@ const preview: Preview = {
},
options: {
storySort: {
+ // Story names sort only where the order names them (SW Header first); other Stories keep file order.
+ includeNames: true,
order: [
"Foundation",
["Color", "Typography", "Layout"],
@@ -47,8 +49,11 @@ const preview: Preview = {
["Overview", "Department Colors", "Full Composition", "Expanded OrgChart"],
"Center Graphic",
"SW",
- ["Header", "Footer", "Popup", "Viewer", "Theme"],
+ ["Header", ["SW Header", "*"], "Footer", ["SW Footer", "*"], "Popup", "Viewer", "Theme"],
"*",
+ // With includeNames, keeps the Loader group after the Runtime Contract Stories (its place without names).
+ "Theme",
+ ["*", "Runtime Contract", ["*", "Loader"]],
],
},
},
diff --git a/docs/sw/sw-viewer-logo-document-menu-2026-10-21.md b/docs/sw/sw-viewer-logo-document-menu-2026-10-21.md
new file mode 100644
index 0000000..bf8fa40
--- /dev/null
+++ b/docs/sw/sw-viewer-logo-document-menu-2026-10-21.md
@@ -0,0 +1,109 @@
+# SW Viewer Header — 로고 파일 열기 메뉴 · 파일명 샘플 목록 · 반응형 (2026-10-21)
+
+기준: `main` 46119ea (미커밋 작업). 승인 범위: `SWViewerHeader` optional Props `logoMenu`, `documents`, `activeDocumentId`, `onDocumentSelect` 추가.
+
+## 1. 동작
+
+| 클릭 대상 | 표시 | 재사용 |
+|---|---|---|
+| Viewer 로고 | 로고 아래 파일 열기 메뉴(Open) | `SWHeaderMore`(SWHeader 반응형 `...` 메뉴) — 같은 면·행·Hover·Escape·바깥 클릭·ArrowUp/Down |
+| 파일명 | 파일명 아래 샘플 문서 목록 | `SWHeaderDocumentTitle`(SWHeader 파일명 목록) `showNew={false}` `showOpen={false}` |
+| 메뉴의 Open | 기존 파일 열기 명령(`onSelect` → `onCommand({ id: "open", itemId: "open" })`) | — |
+| 샘플 항목 | `onDocumentSelect` → 프로그램이 불러오고 `documentTitle`·`activeDocumentId` 갱신 | — |
+
+- 로고 메뉴와 파일명 목록은 하나의 상태(`openMenu`)로 제어해 동시에 열리지 않습니다(포인터·키보드 모두).
+- 로고 메뉴: `title`과 보이는 항목이 모두 있을 때만 타이틀 + 구분선, `title`이 없으면 항목만, 보이는 항목이 없으면 메뉴·버튼을 만들지 않고 기존 로고(`onHomeClick`)로 둡니다. `logoMenu`는 `onHomeClick`보다 우선합니다.
+- 로고 버튼: `aria-haspopup="menu"`, `aria-expanded`, `aria-controls`, ArrowDown = 첫 항목 Focus, 선택·Escape 후 로고로 Focus 복귀.
+- `SWHeaderMore`(내부, 미공개)에 `renderTrigger`·`title`·`label`·`open`/`onOpenChange`·`align`·`className`을 추가했습니다. 기본값은 기존 `...` 버튼 동작 그대로이며, ArrowDown 열기·선택 후 Focus 복귀는 `renderTrigger`를 쓸 때만 적용합니다.
+
+## 2. 반응형 (최종: 실제 가용 폭 기준 3단계, 2026-10-21)
+
+- 고정 Breakpoint 대신 `SWViewerHeader`가 Header 행의 남는 폭을 측정합니다(SWHeader의 measured More와 같은 방식: `data-measuring` 상태에서 각 부분을 자연 폭으로 두고 한 번 측정, 그리지 않음). 파일명은 글자 폭까지만 계산해 짧은 파일명이면 메뉴가 더 오래 유지됩니다.
+- ① 공간 충분: 아이콘+텍스트(1440·1100에서 전체 보기·레이어·설정·속성 모두 표시) → ② 부족: `icon`+`iconOnlyBelow` 항목(전체 보기·레이어·설정·속성)을 한 번에 아이콘만(라벨은 Tooltip·접근성 이름) → ③ 더 부족: `collapseBelow` 항목을 하나씩 More로(값이 큰 것, 같으면 오른쪽 먼저: 어두운 배경 → 2.5D → 전체 보기 → 도면 공간). 넓어지면 역순 복귀. 각 단계가 확보한 폭을 기록해 그 폭이 다시 생길 때만 되돌려 경계에서 깜빡이지 않습니다.
+- Storybook 기본 구성 실측 경계(파일명 `C0060202-001-평면및종단면도.dwg`): 1080px 이상 기본, 1070px 아이콘만, 810px 이하 More. 380px까지 가로 넘침 없음. 줄일 때와 늘릴 때 같은 폭의 상태가 같습니다. 짧은 파일명(`simple.dxf`)에서는 경계가 더 좁은 폭으로 이동합니다.
+- `collapseBelow`·`iconOnlyBelow` Props 형식은 그대로이고 숫자는 순서만 정합니다. `types.ts`의 두 Props 설명("Header width (px) below which…")은 공개 선언 파일 변경을 피하려고 이번에 고치지 않았습니다(`SWViewerHeader`·VIEWER.md에 실제 동작 기재). API 기준 갱신 작업 때 함께 고치는 것을 권합니다.
+- 1180px 미만 간격 2px, 560px 미만 상태 글자 숨김은 메뉴 이동이 아닌 CSS container 규칙으로 그대로 둡니다.
+
+## 2a. 레이어 · 위치 · 스타일 (2026-10-21)
+
+- 파일명 목록이 로고를 가리던 원인: 패널 위 표시를 위해 파일명 요소를 z-index 60으로 올려 로고 면(z-index 2) 위로 파일명 칸이 그려졌습니다. SWHeader는 로고(2) > 파일명(1) 순서를 유지합니다. 수정: 내부 순서를 SWHeader와 같게 되돌리고, 메뉴가 열린 동안만 Header 전체를 기존 뷰어 Dropdown 층(SWViewerMenu, 60)으로 올립니다(닫히면 층 없음).
+- 로고 메뉴: Header 컨테이너 기준(`top: calc(100% + 1px)`, `left: 0`)으로 Header 하단 경계에 바로 붙고 로고 왼쪽에 맞춤. 크기·간격·글자·구분선은 `SWViewerMenu`(상단 메뉴 + Dropdown)와 같게, 색(면 #0e3d2f·Border·글자·Hover)은 유지.
+- 파일명 목록: SWHeader 파일명 목록과 같은 모양 그대로(스타일 변경 없음).
+
+## 3. 공개 API
+
+- Export 170개 유지, 기존 Props 변경·삭제 없음.
+- `public-api.check.mjs`: 6/7 PASS. FAIL 1건은 `components/sw-viewer/SWViewerHeader.d.ts` 해시 변경으로, 승인된 optional Props 추가에 따른 예상 차이입니다. 새로 노출된 내부 선언 파일은 없습니다. 승인 JSON·기준 Commit은 수정하지 않았습니다(별도 API 기준 갱신 작업).
+
+## 4. 검증
+
+- `packages/sw/tests/viewer-logo-document-menu.browser.mjs` 42/42 PASS (증거 `docs/sw/viewer-logo-document-menu-qa/`).
+- `packages/sw/tests/viewer.browser.mjs` 70/70 PASS (참고 뷰어 비교 단계는 참고 URL 없이 생략). 제거된 샘플·파일 열기 버튼 대상 검사를 새 구조에 맞게 수정했습니다.
+- 이 checkout의 `node_modules`에 workspace 링크(`@hm-design/sw` 등)와 `orgchart` 의존성이 없어 루트 `tsc -b`, `build-storybook`은 기존 환경 문제로 실패합니다. Storybook 실행을 위해 `node_modules/@hm-design/sw` → `packages/sw` Junction만 추가했습니다(git 무시 대상).
+
+## 5. 환경 복구 후 재검증 (2026-10-21)
+
+- 원인: `node_modules`가 2026-10-12에 설치된 뒤 Pull로 `package-lock.json`에 workspace 링크 3개(`@hm-design/sw`·`org-chart`·`org-chart-react`)와 `orgchart` 6.0.0 등 13개 패키지가 추가되었으나 `npm install`이 다시 실행되지 않았습니다(설치본에만 있는 패키지·버전 차이 없음).
+- 복구: `npm install`(제자리, `npm ci` 아님). npm이 lockfile의 `org-chart-react` `peerDependencies` 키 순서만 바꿔 설치 직전 백업(=HEAD와 동일)으로 되돌렸습니다. `package.json`·lockfile 변경 없음, Git 상태 설치 전과 동일.
+- `npm run typecheck`(루트 `tsc -b` + 전체 workspace) PASS, `build:packages` PASS, `build-storybook` PASS.
+- Public API: 6/7, FAIL은 `SWViewerHeader.d.ts`뿐. HEAD(46119ea)로 별도 build한 선언 해시가 승인 해시와 같고, 현재와의 차이는 승인된 optional Props 4개, 이미 공개된 `SWHeaderDocument` type import, `document` Prop 설명 한 문장뿐입니다.
+
+| 검사 (storybook-static) | 결과 | 비고 |
+|---|---|---|
+| viewer-logo-document-menu | 42/42 | |
+| viewer-logo-document-menu-firefox (Firefox 158, BiDi) | 30/30 | 증거 `viewer-logo-document-menu-qa/firefox/` |
+| viewer | 70/70 | |
+| keyboard-dismiss / header-composition / home-menu-integration | 22/22 / 161/161 / 23/23 | |
+| integrated-improvement / hover-stability(chromium) / storybook-simplification / footer-popup-viewer-refinement | 90/90 / 201/201 / 9/9 / 47/47 | |
+| overview / dropdown-consolidation / storybook-unused-removal | 53/54 / 227/228 / 92/93 | FAIL은 모두 source guard: 승인 목록 밖 변경 파일로 이번 작업 파일만 표시 |
+| text-weight | 실패(중단) → 수정 후 아래 §6 | 제거된 `파일 열기`·`샘플` 버튼과 `.hm-sw-viewer-header__document-name`을 찾는 기존 단언 |
+| theme-inheritance | 실패(중단) → 수정 후 아래 §6 | Viewer Header 첫 `aria-haspopup="menu"`를 SWViewerMenu(샘플)로 가정, 지금은 로고 메뉴 |
+| followup-four | 실패(중단) | 기존 문제: 5faa1f1에서 삭제된 `sw-header--property-bar` Story를 엶 |
+
+## 6. text-weight · theme-inheritance 갱신 (2026-10-21, 사용자 승인)
+
+- **text-weight:** Header의 Bold 대상(파일명·레이어·설정·속성·Model/Layout = 14px 700 + 1.3px 외곽선)은 그대로 단언하고, 제거된 `파일 열기`·`샘플`·`전체 보기`가 Header 라벨에 없음을 확인합니다. 로고 메뉴 Open 행과 샘플 목록 행은 같은 실행에서 SWHeader `...` 메뉴(400, 외곽선 없음)와 SWHeader 파일명 목록(14px 700 + 1.3px 외곽선)을 기준으로 측정해 굵기·크기·글꼴·외곽선이 같아야 PASS입니다. 로고 메뉴 타이틀은 700(외곽선 없음). Hover 시 글자 위치·크기·굵기 유지 검사를 Open 행·파일명·샘플 행·레이어·Layout에 적용합니다.
+- **theme-inheritance:** 기존 Dropdown 글꼴 상속 검사를 로고 메뉴와 파일명 버튼·샘플 목록으로 옮겼습니다(기본 Noto Sans KR / 뷰어 테마 Dotum). 추가로 두 메뉴의 배경·글자·Border·Hover 색이 기본에서는 SWHeader 토큰 값과 같고, 가장 바깥 SW 범위에 설정한 색 토큰이 두 메뉴까지 상속되는지 확인합니다(검사 중에만 설정 후 제거). 로고 메뉴 면 `#0e3d2f`는 SWHeader `...` 메뉴의 고정값이라 그 값과 비교합니다. 전체 Story 회귀(새 상속 구조 = 기존 구조) 검사는 그대로입니다.
+- **발견·수정한 결함:** theme-inheritance의 뷰어 테마 화면(레이어 패널이 로고 아래에 열린 상태)에서 로고 메뉴가 SWViewerPanel 제목 줄 아래에 깔려 Open을 누를 수 없었습니다. 두 메뉴의 바깥 요소가 z-index 2·1의 쌓임 맥락 안에 있었기 때문입니다. `sw-viewer.css`에 메뉴가 열린 동안만 그 바깥 요소를 기존 뷰어 Dropdown 층(SWViewerMenu, 60)으로 올리는 규칙 한 개를 추가했습니다(닫히면 원래 값). Chromium·Firefox 테스트에 패널 위 표시 검사를 추가했습니다.
+
+| 재실행 (수정 후 storybook-static) | 결과 |
+|---|---|
+| text-weight / theme-inheritance(회귀 67/67 포함) | 51/51 / 28/28 |
+| viewer-logo-document-menu / Firefox 158 | 46/46 / 31/31 |
+| viewer / keyboard-dismiss / header-composition / home-menu-integration | 70/70 / 22/22 / 161/161 / 23/23 |
+| integrated-improvement / hover-stability / storybook-simplification / footer-popup-viewer-refinement | 90/90 / 201/201 / 9/9 / 47/47 |
+| overview / dropdown-consolidation / storybook-unused-removal | 53/54 / 227/228 / 92/93 — source guard만 FAIL(이번 작업 파일 4개) |
+| `npm run typecheck`, `build-storybook`, Public API | PASS, PASS, 6/7(`SWViewerHeader.d.ts` 승인 Props 차이만) |
+
+## 7. 반응형 · 레이어 · 로고 메뉴 스타일 재검증 (2026-10-21, 최종 소스)
+
+이번 실행(영향 범위만): `npm run typecheck` PASS, `build-storybook` PASS, `check:ai-docs` PASS, Public API 6/7(`SWViewerHeader.d.ts`만: 승인 Props + 설명 주석).
+
+| 검사 | 결과 |
+|---|---|
+| viewer-logo-document-menu (Chromium, 폭 Sweep·경계·파일명 길이·열린 상태 Resize·로고 하단 정렬·로고 가림 픽셀·SWViewerMenu 비교 포함) | 58/58 |
+| viewer-logo-document-menu-firefox (Firefox 158) | 34/34 |
+| viewer / text-weight / theme-inheritance(회귀 67/67 포함) | 70/70 / 52/52 / 28/28 |
+
+이번에 재실행하지 않음(이번 라운드에서 `SWHeaderMore`·`sw.css` 등 SW Header 공통 소스 변경 없음, 직전 결과 유지): keyboard-dismiss 22/22, header-composition 161/161, home-menu-integration 23/23, integrated-improvement 90/90, hover-stability 201/201, storybook-simplification 9/9, footer-popup-viewer-refinement 47/47, overview 53/54·dropdown-consolidation 227/228·storybook-unused-removal 92/93(source guard만 FAIL).
+
+## 8. 로고 메뉴 구성 · 파일명 Open · 최소 너비 · Dropdown 면 통일 (2026-10-21)
+
+- 로고 메뉴: 타이틀 `파일` + 아이콘 `파일 열기`(Storybook fixture). 타이틀과 목록은 한 그룹이라 구분선 없음(`SWHeaderMore`가 타이틀 아래 구분선을 그리지 않음). `logoMenu`는 한 그룹 구조라 그룹 사이 구분선이 생길 경우가 없습니다(여러 그룹은 `SWViewerMenu`의 label·separator 데이터로 표현).
+- 파일명 목록: `SWHeaderDocumentTitle`의 `showOpen` 재사용, 목록 아래 SWHeader와 같은 Open(+ New 없음). 목록 외곽 너비 ≥ max(파일명 버튼 외곽 너비, SWHeader 312px), 긴 항목이면 넓어짐(CSS `min-width`·`width: max-content`만). 실측 버튼→목록: 1440 359.5→361.5, 1100 274.5→312, 860 214.5→312, 520/400 140→312, 열린 채 Resize·짧은 파일명도 조건 유지.
+- 두 파일 열기 진입점은 같은 `openFile` 핸들러(로고 항목 `onSelect`, 목록 `onDocumentOpen`). 목록 Open은 `onCommand({ id: "open", itemId: "document" })`도 보냄.
+- Dropdown 면 통일: 기준 520px More 실측값(면 rgb(245,240,229), Border 1px rgb(137,116,69), 반경 4px, 그림자 0 0 12px rgba(0,0,0,.2), 글자 rgb(10,34,29) Noto Sans KR 14/20 400, 행 36px·좌우 12px·간격 8px, Hover rgba(189,223,212,.45)/rgb(18,51,40), 타이틀 rgb(110,103,89) 12/18 500, 구분선 1px rgb(148,136,113))을 기존 `--hm-sw-viewer-more-*`·`--hm-sw-viewer-menu-*` 토큰으로 로고 메뉴에 적용, Header 메뉴 버튼 Dropdown은 `surface="beige"`. 파일명 목록과 단독 `SWViewerMenu`(우클릭)는 제외.
+- 공개 API 추가(보고): `logoMenu.items[].icon?`, `onDocumentOpen?`, `documentOpenLabel?`(모두 Optional, `SWViewerHeader.d.ts` 안, Export 170 유지). 아이콘·Open 핸들러를 넘길 기존 Props가 없어 추가했습니다.
+- 검증(최종 소스): typecheck·build-storybook·check:ai-docs PASS, Public API 6/7(`SWViewerHeader.d.ts`만), viewer-logo-document-menu 63/63, Firefox 36/36, viewer 70/70, theme-inheritance 27/27(고정 면 검사 → 토큰 검사로 대체), 이전 실행(같은 라운드 SWHeaderMore 변경 후): text-weight 52/52, keyboard-dismiss 22/22, header-composition 161/161, integrated-improvement 90/90.
+
+## 9. Dropdown 색 최종 (2026-10-21)
+
+- 기준: 일반 SWHeader의 `...` 메뉴(`.hm-sw-header__more-menu`). 로고 메뉴(같은 컴포넌트)는 Viewer 색 재정의를 제거해 sw.css 색을 그대로 사용하고, Header 메뉴 버튼 Dropdown·More(SWViewerMenu, Header 안)는 Viewer 범위 CSS로 같은 값을 칠합니다: 면 #0e3d2f·그림자 0 4px 12px rgba(0,0,0,.5)(sw.css 고정값), Border `--hm-sw-header-object-border-color`, 글자 `--hm-sw-header-text-color`(#f6f4f2), 보조 글자 같은 색 80%, Hover `--hm-sw-header-hover`(rgba(255,255,255,.08), 글자 유지), 구분선 `--hm-sw-header-document-menu-divider-color`. 크기·간격·글자 크기는 SWViewerMenu 그대로. 파일명 목록·단독 SWViewerMenu는 제외.
+- 검증(이번 실행): build-storybook, viewer-logo-document-menu 63/63(More·Dropdown·로고 메뉴 색 = 같은 실행에서 측정한 SWHeader `...` 메뉴), Firefox 36/36, viewer 70/70, theme-inheritance 28/28. 재사용: typecheck(직전 CSS 외 소스 변경 없음), text-weight 52/52, keyboard-dismiss 22/22, header-composition 161/161, integrated-improvement 90/90, Public API 6/7(`SWViewerHeader.d.ts`만).
+
+## 10. 파일 열기 버튼으로 변경 (2026-10-21)
+
+- 로고 클릭 메뉴 대신 로고 오른쪽 파일 열기 버튼: SWHeader `documentOpenPlacement="header"`와 같은 `SWToolButton appearance="document"` 면 + 폴더 아이콘(테두리·배경 없음, 사용자 요청) + `documentOpenLabel`. 기존 `onDocumentOpen`·`documentOpenLabel` 재사용(목록 아래 Open → Header 버튼). 공간이 부족하면 측정 2단계에서 라벨만 숨김(Tooltip·접근성 이름 `파일 열기`), More로 이동하지 않음.
+- 파일명 목록: Open·+ New 없음(`showOpen={false}`), 최소 너비 조건 유지. 파일명 영역 컨테이너(`.hm-sw-viewer-header__document`)는 Open 버튼 뒤에서 왼쪽 padding 0, 파일명 버튼(`.hm-sw-header__document-button`) padding은 변경하지 않음(사용자 결정).
+- 로고 메뉴: 뷰어 화면·Story·AI 예시에서 사용 중단. `logoMenu` Prop과 `SWHeaderMore` 확장(제목·아이콘·외부 트리거)은 공개·승인된 API라 코드 유지 — API 기준 갱신 작업에서 삭제(또는 유지) 결정 필요.
+- 검증: viewer-logo-document-menu 47/47, Firefox 23/23, viewer 70/70, text-weight 50/50, theme-inheritance 25/25, check:ai-docs PASS.
diff --git a/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-document-list.png b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-document-list.png
new file mode 100644
index 0000000..5f3425c
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-document-list.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-header-open.png b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-header-open.png
new file mode 100644
index 0000000..db4a2a9
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-header-open.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-logo-menu-no-title.png b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-logo-menu-no-title.png
new file mode 100644
index 0000000..8ea5bc2
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-logo-menu-no-title.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-logo-menu-title.png b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-logo-menu-title.png
new file mode 100644
index 0000000..a9bb41c
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-logo-menu-title.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-logo-menu.png b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-logo-menu.png
new file mode 100644
index 0000000..a1dc1c3
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-logo-menu.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-responsive-520-logo-menu.png b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-responsive-520-logo-menu.png
new file mode 100644
index 0000000..b147c53
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-responsive-520-logo-menu.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-viewer-narrow.png b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-viewer-narrow.png
new file mode 100644
index 0000000..62bc0ce
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/firefox/firefox-viewer-narrow.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/firefox/viewer-logo-document-menu-firefox.json b/docs/sw/viewer-logo-document-menu-qa/firefox/viewer-logo-document-menu-firefox.json
new file mode 100644
index 0000000..b1b7096
--- /dev/null
+++ b/docs/sw/viewer-logo-document-menu-qa/firefox/viewer-logo-document-menu-firefox.json
@@ -0,0 +1,450 @@
+{
+ "base": "http://127.0.0.1:6040",
+ "checks": [
+ {
+ "n": "SWHeader ... menu opens (reference colors)",
+ "ok": true,
+ "d": {
+ "colors": {
+ "background": "rgb(14, 61, 47)",
+ "border": "rgb(19, 86, 67)",
+ "shadow": "rgba(0, 0, 0, 0.5) 0px 4px 12px 0px",
+ "item": "rgb(246, 244, 242)"
+ },
+ "metrics": {
+ "border": "1px solid",
+ "radius": "3px",
+ "padding": "4px 4px",
+ "minWidth": "200px",
+ "font": "14px/normal 400",
+ "itemHeight": 32,
+ "itemInset": 5,
+ "itemPadding": "10px 10px",
+ "itemGap": "12px",
+ "itemRadius": "2px",
+ "itemFont": "Noto Sans KR 14px/normal 400"
+ }
+ }
+ },
+ {
+ "n": "SWHeader ... menu Escape closes, focus back on ...",
+ "ok": true
+ },
+ {
+ "n": "SWViewerMenu reference opens (36px rows, 4px padding)",
+ "ok": true,
+ "d": {
+ "border": "1px solid",
+ "radius": "4px",
+ "padding": "4px 4px",
+ "minWidth": "160px",
+ "font": "14px/20px 400",
+ "itemHeight": 36,
+ "itemInset": 5,
+ "itemPadding": "12px 12px",
+ "itemGap": "8px",
+ "itemRadius": "2px",
+ "itemFont": "Noto Sans KR 14px/20px 400"
+ }
+ },
+ {
+ "n": "logo opens no dropdown",
+ "ok": true,
+ "d": {
+ "menu": false,
+ "list": false,
+ "nameExpanded": "false",
+ "name": "C0060202-001-평면및종단면도.dwg",
+ "focus": "Name\n Description\n Default\n Control \n \n \n \n \n propertyName*\n \n This is a short description\n \n summary\n \n \n \n defaultValue\n \n Set string\n \n \n propertyName*\n \n This is a short description\n \n summary\n \n \n \n defaultValue\n \n Set string\n \n \n propertyName*\n \n This is a short description\n \n summary\n \n \n \n defaultValue\n \n Set string\n \n \n \n\n\n\n \n No Preview\n Sorry, but you either have no stories or none are selected somehow.\n \n Please check the Storybook config.\n Try reloading the page.\n \n \n If the problem persists, check the browser console, or the terminal you've run Storybook from.\n \n \n\n\n\n \n \n \n The component failed to render properly, likely due to a configuration issue in Storybook.\n Here are some common causes and how you can address them:\n \n \n \n Missing Context/Providers: You can use decorators to supply specific\n contexts or providers, which are sometimes necessary for components to render correctly. For\n detailed instructions on using decorators, please visit the\n Decorators documentation.\n \n \n Misconfigured Webpack or Vite: Verify that Storybook picks up all necessary\n settings for loaders, plugins, and other relevant parameters. You can find step-by-step\n guides for configuring\n Webpack or\n Vite\n with Storybook.\n \n \n Missing Environment Variables: Your Storybook may require specific\n environment variables to function as intended. You can set up custom environment variables\n as outlined in the\n Environment Variables documentation.\n \n \n \n \n\n\n HM DESIGN SYSTEM / SWSW Viewer — SWViewerHeader참고 DWG/DXF 뷰어의 상단 메뉴를 패키지 컴포넌트로 옮긴 뷰어 모드 전용 Header입니다. 로고 면 → 파일명 면 → 객체 수정 면(메뉴 그룹) → 오른쪽 패널·상태·정보 영역이며, 메뉴는 groups 데이터로 구성합니다. 레이어·설정·속성 토글은 팝업을 열고, 정보 버튼은 모달 팝업을 엽니다.기본형 (1440px, 도면 열림)로고 오른쪽 파일 열기 버튼(공간이 부족하면 테두리 아이콘만)으로 파일을 열고, 파일명을 누르면 샘플 도면 목록이 열립니다. Model/Layout 탭, 2.5D·어두운 배경 스위치, 패널 토글을 직접 눌러 보세요. Command 줄에 onCommand 결과가 표시됩니다.Command: 없음파일 열기C0060202-001-평면및종단면도.dwgModel17,377Layout237전체 보기2.5D어두운 배경레이어설정속성도면 열림: C0060202-001-평면및종단면도.dwg 열림\n \n \n function __onViteAppLoadingError(event) {\n const hostname = globalThis.location.hostname;\n if (hostname !== 'localhost' && globalThis.CONFIG_TYPE === 'DEVELOPMENT') {\n const message = `Failed to load the Storybook preview file 'vite-app.js':\n\n It looks like you're visiting the Storybook development server on another hostname than localhost: '${hostname}', but you haven't configured the necessary security features to support this.\n Please re-run your Storybook development server with the '--host ${hostname}' flag, or manually configure your Vite allowedHosts configuration with viteFinal.\n\n See:`;\n const docs = [\n 'https://storybook.js.org/docs/api/cli-options#dev',\n 'https://storybook.js.org/docs/api/main-config/main-config-vite-final',\n 'https://vite.dev/config/server-options.html#server-allowedhosts',\n ];\n console.error(`${message}\\n${docs.map((doc) => `- ${doc}`).join('\\n')}`);\n\n document.getElementById('storybook-root').innerHTML =\n `
`;\n return;\n }\n }\n \n \n\n\n #storybook-highlights-menu {\n position: absolute;\n z-index: 2147483647;\n width: 300px;\n padding: 0px;\n margin: 15px 0 0 0;\n transform: translateX(-50%);\n font-family: \"Nunito Sans\", -apple-system, \".SFNSText-Regular\", \"San Francisco\", BlinkMacSystemFont, \"Segoe UI\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 12px;\n background: white;\n border: none;\n border-radius: 6px;\n box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.05), 0 5px 15px 0 rgba(0, 0, 0, 0.1);\n color: #2E3438;\n }\n #storybook-highlights-menu ul {\n list-style: none;\n margin: 0;\n padding: 0;\n }\n #storybook-highlights-menu > ul {\n max-height: 300px;\n overflow-y: auto;\n padding: 4px 0;\n }\n #storybook-highlights-menu li {\n padding: 0 4px;\n margin: 0;\n }\n #storybook-highlights-menu li > :not(ul) {\n display: flex;\n padding: 8px;\n margin: 0;\n align-items: center;\n gap: 8px;\n border-radius: 4px;\n }\n #storybook-highlights-menu button {\n width: 100%;\n border: 0;\n background: transparent;\n color: inherit;\n text-align: left;\n font-family: inherit;\n font-size: inherit;\n }\n #storybook-highlights-menu button:focus-visible {\n outline-color: #029CFD;\n }\n #storybook-highlights-menu button:hover {\n background: rgba(2, 156, 253, 0.07);\n color: #029CFD;\n cursor: pointer;\n }\n #storybook-highlights-menu li code {\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n line-height: 16px;\n font-size: 11px;\n }\n #storybook-highlights-menu li svg {\n flex-shrink: 0;\n margin: 1px;\n color: #73828C;\n }\n #storybook-highlights-menu li > button:hover svg, #storybook-highlights-menu li > button:focus-visible svg {\n color: #029CFD;\n }\n #storybook-highlights-menu .element-list li svg {\n display: none;\n }\n #storybook-highlights-menu li.selectable svg, #storybook-highlights-menu li.selected svg {\n display: block;\n }\n #storybook-highlights-menu .menu-list {\n border-top: 1px solid rgba(38, 85, 115, 0.15);\n }\n #storybook-highlights-menu .menu-list > li:not(:last-child) {\n padding-bottom: 4px;\n margin-bottom: 4px;\n border-bottom: 1px solid rgba(38, 85, 115, 0.15);\n }\n #storybook-highlights-menu .menu-items, #storybook-highlights-menu .menu-items li {\n padding: 0;\n }\n #storybook-highlights-menu .menu-item {\n display: flex;\n }\n #storybook-highlights-menu .menu-item-content {\n display: flex;\n flex-direction: column;\n flex-grow: 1;\n }",
+ "log": "Command: 없음"
+ }
+ },
+ {
+ "n": "file open button after the logo: SWHeader document surface, icon + 파일 열기",
+ "ok": true,
+ "d": {
+ "surface": true,
+ "afterLogo": true,
+ "label": "파일 열기",
+ "icon": 1
+ }
+ },
+ {
+ "n": "file open icon: no border, transparent, no shadow",
+ "ok": true,
+ "d": "0px | none | rgb(240, 247, 245) | 0px | rgba(0, 0, 0, 0) | none"
+ },
+ {
+ "n": "file open button runs the existing file open",
+ "ok": true,
+ "d": {
+ "menu": false,
+ "list": false,
+ "nameExpanded": "false",
+ "name": "C0060202-001-평면및종단면도.dwg",
+ "focus": "파일 열기",
+ "log": "Command: 파일 선택창 (미연결) → document/open"
+ }
+ },
+ {
+ "n": "open list never covers the logo cap (pixels identical)",
+ "ok": true
+ },
+ {
+ "n": "file name click opens the 3 samples only (no Open / + New), SWHeader face",
+ "ok": true,
+ "d": {
+ "rows": [
+ "BasicSample.dwg",
+ "C0060202-001-평면및종단면도.dwg",
+ "simple.dxf"
+ ],
+ "actions": 0,
+ "active": "C0060202-001-평면및종단면도.dwg",
+ "width": 361.5,
+ "button": 359.5,
+ "left": -2,
+ "right": 0,
+ "row": 32,
+ "gap": 0,
+ "background": "rgb(16, 49, 39)"
+ }
+ },
+ {
+ "n": "document list >= file-name button (outer) and >= 312px, covers it",
+ "ok": true,
+ "d": {
+ "rows": [
+ "BasicSample.dwg",
+ "C0060202-001-평면및종단면도.dwg",
+ "simple.dxf"
+ ],
+ "actions": 0,
+ "active": "C0060202-001-평면및종단면도.dwg",
+ "width": 361.5,
+ "button": 359.5,
+ "left": -2,
+ "right": 0,
+ "row": 32,
+ "gap": 0,
+ "background": "rgb(16, 49, 39)"
+ }
+ },
+ {
+ "n": "list painted on top (not cut)",
+ "ok": true
+ },
+ {
+ "n": "sample select loads it, renames, closes, focus on the file name",
+ "ok": true,
+ "d": {
+ "menu": false,
+ "list": false,
+ "nameExpanded": "false",
+ "name": "simple.dxf",
+ "focus": "name",
+ "log": "Command: 파일 선택창 (미연결) → document/open → samples/sample-dxf"
+ }
+ },
+ {
+ "n": "ArrowDown opens the list on its first row",
+ "ok": true,
+ "d": {
+ "menu": false,
+ "list": true,
+ "nameExpanded": "true",
+ "name": "simple.dxf",
+ "focus": "BasicSample.dwg",
+ "log": "Command: 파일 선택창 (미연결) → document/open → samples/sample-dxf"
+ }
+ },
+ {
+ "n": "Escape closes the list and refocuses the file name",
+ "ok": true,
+ "d": {
+ "menu": false,
+ "list": false,
+ "nameExpanded": "false",
+ "name": "simple.dxf",
+ "focus": "name",
+ "log": "Command: 파일 선택창 (미연결) → document/open → samples/sample-dxf"
+ }
+ },
+ {
+ "n": "document list above an open SWViewerPanel",
+ "ok": true
+ },
+ {
+ "n": "no horizontal overflow at 1440 / 1100 / 860 / 520",
+ "ok": true,
+ "d": [
+ [
+ 1440,
+ 0,
+ 0
+ ],
+ [
+ 1100,
+ 0,
+ 0
+ ],
+ [
+ 860,
+ 0,
+ 0
+ ],
+ [
+ 520,
+ 0,
+ 0
+ ]
+ ]
+ },
+ {
+ "n": "layer / settings / properties always in the header with tooltips",
+ "ok": true,
+ "d": [
+ [
+ 1440,
+ [
+ [
+ "레이어",
+ true,
+ true
+ ],
+ [
+ "설정",
+ true,
+ true
+ ],
+ [
+ "속성",
+ true,
+ true
+ ]
+ ]
+ ],
+ [
+ 1100,
+ [
+ [
+ "레이어",
+ false,
+ true
+ ],
+ [
+ "설정",
+ false,
+ true
+ ],
+ [
+ "속성",
+ false,
+ true
+ ]
+ ]
+ ],
+ [
+ 860,
+ [
+ [
+ "레이어",
+ false,
+ true
+ ],
+ [
+ "설정",
+ false,
+ true
+ ],
+ [
+ "속성",
+ false,
+ true
+ ]
+ ]
+ ],
+ [
+ 520,
+ [
+ [
+ "레이어",
+ false,
+ true
+ ],
+ [
+ "설정",
+ false,
+ true
+ ],
+ [
+ "속성",
+ false,
+ true
+ ]
+ ]
+ ]
+ ]
+ },
+ {
+ "n": "1440 full (파일 열기 + 전체 보기 labels, no More); 1100 icons only, no More; 860 / 520 icons + More",
+ "ok": true,
+ "d": [
+ [
+ 1440,
+ true,
+ false,
+ [
+ true,
+ "",
+ 34
+ ],
+ [
+ true,
+ true,
+ true
+ ]
+ ],
+ [
+ 1100,
+ true,
+ false,
+ [
+ false,
+ "파일 열기",
+ 34
+ ],
+ [
+ false,
+ false,
+ false
+ ]
+ ],
+ [
+ 860,
+ true,
+ true,
+ [
+ false,
+ "파일 열기",
+ 34
+ ],
+ [
+ false,
+ false,
+ false
+ ]
+ ],
+ [
+ 520,
+ false,
+ true,
+ [
+ false,
+ "파일 열기",
+ 34
+ ],
+ [
+ false,
+ false,
+ false
+ ]
+ ]
+ ]
+ },
+ {
+ "n": "logo, file open, file name and toolbar never overlap",
+ "ok": true,
+ "d": [
+ [
+ 1440,
+ [
+ 65,
+ 65,
+ 203.61666870117188,
+ 205.61666870117188,
+ 565.1166687011719,
+ 567.11669921875
+ ]
+ ],
+ [
+ 1100,
+ [
+ 65,
+ 65,
+ 136,
+ 138,
+ 412.5,
+ 414.5
+ ]
+ ],
+ [
+ 860,
+ [
+ 65,
+ 65,
+ 136,
+ 138,
+ 352.5,
+ 354.5
+ ]
+ ],
+ [
+ 520,
+ [
+ 65,
+ 65,
+ 136,
+ 138,
+ 278,
+ 280
+ ]
+ ]
+ ]
+ },
+ {
+ "n": "520: document list shown inside the header width",
+ "ok": true
+ },
+ {
+ "n": "520: icon-only file open runs the file open",
+ "ok": true
+ },
+ {
+ "n": "narrow header (771px): icon toggles open / close panels, pressed #FFC600, tooltip",
+ "ok": true,
+ "d": [
+ {
+ "name": "레이어",
+ "pressed": "true",
+ "color": "rgb(255, 198, 0)",
+ "title": "레이어 패널 열기 / 닫기",
+ "open": true,
+ "closed": true
+ },
+ {
+ "name": "설정",
+ "pressed": "true",
+ "color": "rgb(255, 198, 0)",
+ "title": "옵션 패널 열기 / 닫기",
+ "open": true,
+ "closed": true
+ },
+ {
+ "name": "속성",
+ "pressed": "true",
+ "color": "rgb(255, 198, 0)",
+ "title": "속성 패널 열기 / 닫기",
+ "open": true,
+ "closed": true
+ }
+ ]
+ },
+ {
+ "n": "no console errors",
+ "ok": true,
+ "d": []
+ }
+ ],
+ "errors": []
+}
\ No newline at end of file
diff --git a/docs/sw/viewer-logo-document-menu-qa/logo-menu-no-title.png b/docs/sw/viewer-logo-document-menu-qa/logo-menu-no-title.png
new file mode 100644
index 0000000..1ec3277
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/logo-menu-no-title.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/logo-menu-title.png b/docs/sw/viewer-logo-document-menu-qa/logo-menu-title.png
new file mode 100644
index 0000000..df737be
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/logo-menu-title.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/responsive-1100.png b/docs/sw/viewer-logo-document-menu-qa/responsive-1100.png
new file mode 100644
index 0000000..3f49a1f
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/responsive-1100.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/responsive-1440.png b/docs/sw/viewer-logo-document-menu-qa/responsive-1440.png
new file mode 100644
index 0000000..bbb7e78
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/responsive-1440.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/responsive-520-document-list.png b/docs/sw/viewer-logo-document-menu-qa/responsive-520-document-list.png
new file mode 100644
index 0000000..331dbfc
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/responsive-520-document-list.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/responsive-520-logo-menu.png b/docs/sw/viewer-logo-document-menu-qa/responsive-520-logo-menu.png
new file mode 100644
index 0000000..389bc78
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/responsive-520-logo-menu.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/responsive-520.png b/docs/sw/viewer-logo-document-menu-qa/responsive-520.png
new file mode 100644
index 0000000..fce4ea0
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/responsive-520.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/responsive-860.png b/docs/sw/viewer-logo-document-menu-qa/responsive-860.png
new file mode 100644
index 0000000..0a69255
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/responsive-860.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/viewer-1440-idle.png b/docs/sw/viewer-logo-document-menu-qa/viewer-1440-idle.png
new file mode 100644
index 0000000..085294b
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/viewer-1440-idle.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/viewer-520-open-icon.png b/docs/sw/viewer-logo-document-menu-qa/viewer-520-open-icon.png
new file mode 100644
index 0000000..ef4b252
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/viewer-520-open-icon.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/viewer-document-list-open.png b/docs/sw/viewer-logo-document-menu-qa/viewer-document-list-open.png
new file mode 100644
index 0000000..3cbff16
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/viewer-document-list-open.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/viewer-logo-document-menu-results.json b/docs/sw/viewer-logo-document-menu-qa/viewer-logo-document-menu-results.json
new file mode 100644
index 0000000..28cb05d
--- /dev/null
+++ b/docs/sw/viewer-logo-document-menu-qa/viewer-logo-document-menu-results.json
@@ -0,0 +1,691 @@
+{
+ "storybook": "http://127.0.0.1:6040",
+ "results": [
+ {
+ "name": "reference SWViewerMenu: 36px rows, 4px padding / radius, 14px 400, 4px under its trigger",
+ "result": "PASS",
+ "detail": {
+ "colors": {
+ "background": "rgb(14, 61, 47)",
+ "border": "rgb(19, 86, 67)",
+ "shadow": "rgba(0, 0, 0, 0.5) 0px 4px 12px 0px",
+ "item": "rgb(246, 244, 242)",
+ "label": "rgb(246, 244, 242)"
+ },
+ "metrics": {
+ "border": "1px solid",
+ "radius": "4px",
+ "padding": "4px",
+ "minWidth": "160px",
+ "font": "14px/20px 400",
+ "itemHeight": 36,
+ "itemInset": 5,
+ "itemPadding": "12px 12px",
+ "itemGap": "8px",
+ "itemRadius": "2px",
+ "itemFont": "\"Noto Sans KR\" 14px/20px 400",
+ "itemOutline": "none",
+ "label": "6px 12px 12px/18px 500",
+ "separator": "1px 4px -4px",
+ "icon": "16x16 @12",
+ "textLeft": 36
+ },
+ "place": {
+ "dy": 4,
+ "dx": 0
+ }
+ }
+ },
+ {
+ "name": "More (520px) colors = SWHeader ... menu (face, border, shadow, text); caption = text color",
+ "result": "PASS",
+ "detail": {
+ "more": {
+ "background": "rgb(14, 61, 47)",
+ "border": "rgb(19, 86, 67)",
+ "shadow": "rgba(0, 0, 0, 0.5) 0px 4px 12px 0px",
+ "item": "rgb(246, 244, 242)",
+ "label": "rgb(246, 244, 242)"
+ },
+ "hover": "rgba(255, 255, 255, 0.08) / rgb(246, 244, 242)",
+ "swHeader": {
+ "background": "rgb(14, 61, 47)",
+ "border": "rgb(19, 86, 67)",
+ "shadow": "rgba(0, 0, 0, 0.5) 0px 4px 12px 0px",
+ "item": "rgb(246, 244, 242)",
+ "label": null
+ }
+ }
+ },
+ {
+ "name": "header dropdown (상단 메뉴 + Dropdown) = More face and colors",
+ "result": "PASS",
+ "detail": {
+ "dropdown": {
+ "background": "rgb(14, 61, 47)",
+ "border": "rgb(19, 86, 67)",
+ "shadow": "rgba(0, 0, 0, 0.5) 0px 4px 12px 0px",
+ "item": "rgb(246, 244, 242)",
+ "label": "rgb(246, 244, 242)"
+ },
+ "more": {
+ "background": "rgb(14, 61, 47)",
+ "border": "rgb(19, 86, 67)",
+ "shadow": "rgba(0, 0, 0, 0.5) 0px 4px 12px 0px",
+ "item": "rgb(246, 244, 242)",
+ "label": "rgb(246, 244, 242)"
+ }
+ }
+ },
+ {
+ "name": "SWHeader ... menu still right-aligned under its trigger (no title, no divider)",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "SWHeader ... menu Escape closes and refocuses ...",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "logo opens no dropdown (no menu trigger)",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "file open button after the logo: SWHeader document surface, icon + 파일 열기",
+ "result": "PASS",
+ "detail": {
+ "surface": true,
+ "afterLogo": true,
+ "beforeName": true,
+ "label": "파일 열기",
+ "icons": 1
+ }
+ },
+ {
+ "name": "file open icon: no border, transparent, no shadow; 34px slot",
+ "result": "PASS",
+ "detail": {
+ "style": "0px none rgb(240, 247, 245) | 0px | rgba(0, 0, 0, 0) | none",
+ "size": [
+ 34,
+ 34
+ ]
+ }
+ },
+ {
+ "name": "file name area: no left padding, right after the file open button",
+ "result": "PASS",
+ "detail": [
+ "0px",
+ 2
+ ]
+ },
+ {
+ "name": "no 샘플 header button, no + New",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "file open button runs the existing file open (onDocumentOpen + onCommand open/document)",
+ "result": "PASS",
+ "detail": "Command: 파일 선택창 (미연결) → document/open"
+ },
+ {
+ "name": "file name is the SWHeader document button",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "open list never covers the logo cap (pixels identical)",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "file name click opens the sample list",
+ "result": "PASS",
+ "detail": [
+ "BasicSample.dwg",
+ "C0060202-001-평면및종단면도.dwg",
+ "simple.dxf"
+ ]
+ },
+ {
+ "name": "no Open and no + New below the samples",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "current sample marked active",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "SWHeader list face (32px rows, #103127, at least the SWHeader 312px)",
+ "result": "PASS",
+ "detail": {
+ "background": "rgb(16, 49, 39)",
+ "width": 362,
+ "row": 32
+ }
+ },
+ {
+ "name": "list opens directly below the file name",
+ "result": "PASS",
+ "detail": {
+ "list": {
+ "x": 202.90625,
+ "y": 368.5,
+ "width": 361.5,
+ "height": 98
+ },
+ "name": {
+ "x": 204.90625,
+ "y": 318.5,
+ "width": 359.5,
+ "height": 50
+ }
+ }
+ },
+ {
+ "name": "sample select loads it and renames the file",
+ "result": "PASS",
+ "detail": "Command: 파일 선택창 (미연결) → document/open → samples/sample-dxf"
+ },
+ {
+ "name": "list closes after select, focus on the file name",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "ArrowDown opens the list on its first row",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "Escape closes the list and refocuses the file name",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "outside click closes the list",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "1440: icon + 파일 열기; narrower: icon only (tooltip / name 파일 열기, 34px icon slot kept)",
+ "result": "PASS",
+ "detail": {
+ "first": {
+ "width": 1440,
+ "label": true,
+ "aria": null,
+ "title": "",
+ "icon": [
+ 34,
+ 34
+ ],
+ "overflow": 0
+ },
+ "firstIcon": {
+ "width": 1080,
+ "label": false,
+ "aria": "파일 열기",
+ "title": "파일 열기",
+ "icon": [
+ 34,
+ 34
+ ],
+ "overflow": 0
+ }
+ }
+ },
+ {
+ "name": "label comes back at the same widths when the header grows; no overflow",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "file open never moves into More",
+ "result": "PASS",
+ "detail": [
+ "Model · 17,377개",
+ "Layout · 237개",
+ "전체 보기",
+ "2.5D",
+ "어두운 배경"
+ ]
+ },
+ {
+ "name": "icon-only button runs the same file open",
+ "result": "PASS",
+ "detail": "Command: 파일 선택창 (미연결) → document/open → samples/sample-dxf → 파일 선택창 (미연결) → document/open"
+ },
+ {
+ "name": "document list above an open SWViewerPanel",
+ "result": "PASS",
+ "detail": "60"
+ },
+ {
+ "name": "closed list drops back (no raised layer at rest), cap over the open button and file name",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "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": "layer / settings / properties always in the header",
+ "result": "PASS",
+ "detail": [
+ [
+ "레이어",
+ "설정",
+ "속성"
+ ],
+ [
+ "레이어",
+ "설정",
+ "속성"
+ ],
+ [
+ "레이어",
+ "설정",
+ "속성"
+ ],
+ [
+ "레이어",
+ "설정",
+ "속성"
+ ]
+ ]
+ },
+ {
+ "name": "1440: full items with labels (전체 보기 shown), no More; 1100: icons only, no More",
+ "result": "PASS",
+ "detail": [
+ [
+ 1440,
+ true,
+ false
+ ],
+ [
+ 1100,
+ true,
+ false
+ ],
+ [
+ 860,
+ true,
+ true
+ ],
+ [
+ 520,
+ false,
+ true
+ ]
+ ]
+ },
+ {
+ "name": "860: icons only (tooltips kept) + More",
+ "result": "PASS",
+ "detail": {
+ "width": 860,
+ "overflow": 0,
+ "objectOverflow": 0,
+ "toggles": [
+ {
+ "name": "레이어",
+ "label": false,
+ "title": "레이어 패널 열기 / 닫기"
+ },
+ {
+ "name": "설정",
+ "label": false,
+ "title": "옵션 패널 열기 / 닫기"
+ },
+ {
+ "name": "속성",
+ "label": false,
+ "title": "속성 패널 열기 / 닫기"
+ }
+ ],
+ "fitShown": true,
+ "more": true,
+ "logoRight": 65,
+ "docLeft": 138,
+ "docRight": 352.5,
+ "objectLeft": 354.5
+ }
+ },
+ {
+ "name": "520: icons only, then More for what still does not fit",
+ "result": "PASS",
+ "detail": {
+ "width": 520,
+ "overflow": 0,
+ "objectOverflow": 0,
+ "toggles": [
+ {
+ "name": "레이어",
+ "label": false,
+ "title": "레이어 패널 열기 / 닫기"
+ },
+ {
+ "name": "설정",
+ "label": false,
+ "title": "옵션 패널 열기 / 닫기"
+ },
+ {
+ "name": "속성",
+ "label": false,
+ "title": "속성 패널 열기 / 닫기"
+ }
+ ],
+ "fitShown": false,
+ "more": true,
+ "logoRight": 65,
+ "docLeft": 138,
+ "docRight": 278,
+ "objectLeft": 280
+ }
+ },
+ {
+ "name": "logo, file name and toolbar never overlap",
+ "result": "PASS",
+ "detail": [
+ [
+ 1440,
+ 65,
+ 204.90625,
+ 564.40625,
+ 566.40625
+ ],
+ [
+ 1100,
+ 65,
+ 138,
+ 412.5,
+ 414.5
+ ],
+ [
+ 860,
+ 65,
+ 138,
+ 352.5,
+ 354.5
+ ],
+ [
+ 520,
+ 65,
+ 138,
+ 278,
+ 280
+ ]
+ ]
+ },
+ {
+ "name": "520: header + More hold every item exactly once (panel toggles never in More)",
+ "result": "PASS",
+ "detail": {
+ "inHeader": [
+ "레이어",
+ "설정",
+ "속성",
+ "정보 및 라이선스"
+ ],
+ "moreItems": [
+ "Model",
+ "Layout",
+ "전체 보기",
+ "2.5D",
+ "어두운 배경"
+ ]
+ }
+ },
+ {
+ "name": "icon toggle pressed + command at 520",
+ "result": "PASS",
+ "detail": "Command: layers=true"
+ },
+ {
+ "name": "520: document list shown, inside the header width",
+ "result": "PASS",
+ "detail": {
+ "list": {
+ "x": 136,
+ "y": 1171.5,
+ "width": 312,
+ "height": 98
+ },
+ "header": {
+ "x": 40,
+ "y": 1120.5,
+ "width": 520,
+ "height": 52
+ }
+ }
+ },
+ {
+ "name": "narrow header (771px): icon toggles with tooltip, hover, pressed #FFC600, panel open / close",
+ "result": "PASS",
+ "detail": [
+ {
+ "name": "레이어",
+ "iconOnly": true,
+ "title": "레이어 패널 열기 / 닫기",
+ "hover": "rgb(38, 102, 81)",
+ "open": true,
+ "pressed": "true",
+ "color": "rgb(255, 198, 0)",
+ "closed": true
+ },
+ {
+ "name": "설정",
+ "iconOnly": true,
+ "title": "옵션 패널 열기 / 닫기",
+ "hover": "rgb(38, 102, 81)",
+ "open": true,
+ "pressed": "true",
+ "color": "rgb(255, 198, 0)",
+ "closed": true
+ },
+ {
+ "name": "속성",
+ "iconOnly": true,
+ "title": "속성 패널 열기 / 닫기",
+ "hover": "rgb(38, 102, 81)",
+ "open": true,
+ "pressed": "true",
+ "color": "rgb(255, 198, 0)",
+ "closed": true
+ }
+ ]
+ },
+ {
+ "name": "document list >= file-name button (outer), covers it, >= 312px, no row cut — long / short name, 1440–400, resize while open",
+ "result": "PASS",
+ "detail": {
+ "bad": [],
+ "cases": [
+ {
+ "label": "long@1440",
+ "button": 359.5,
+ "list": 361.5,
+ "left": -2,
+ "right": 0,
+ "cut": []
+ },
+ {
+ "label": "long@1100",
+ "button": 274.5,
+ "list": 312,
+ "left": -2,
+ "right": 35.5,
+ "cut": []
+ },
+ {
+ "label": "long@860",
+ "button": 214.5,
+ "list": 312,
+ "left": -2,
+ "right": 95.5,
+ "cut": []
+ },
+ {
+ "label": "long@520",
+ "button": 140,
+ "list": 312,
+ "left": -2,
+ "right": 170,
+ "cut": []
+ },
+ {
+ "label": "long@400",
+ "button": 91,
+ "list": 312,
+ "left": -2,
+ "right": 219,
+ "cut": []
+ },
+ {
+ "label": "short@1440",
+ "button": 359.5,
+ "list": 361.5,
+ "left": -2,
+ "right": 0,
+ "cut": []
+ },
+ {
+ "label": "short@1100",
+ "button": 155.2,
+ "list": 312,
+ "left": -2,
+ "right": 154.8,
+ "cut": []
+ },
+ {
+ "label": "short@860",
+ "button": 171.8,
+ "list": 312,
+ "left": -2,
+ "right": 138.3,
+ "cut": []
+ },
+ {
+ "label": "short@520",
+ "button": 140,
+ "list": 312,
+ "left": -2,
+ "right": 170,
+ "cut": []
+ },
+ {
+ "label": "short@400",
+ "button": 91,
+ "list": 312,
+ "left": -2,
+ "right": 219,
+ "cut": []
+ },
+ {
+ "label": "open-resize@1200",
+ "button": 207.2,
+ "list": 312,
+ "left": -2,
+ "right": 102.8,
+ "cut": []
+ },
+ {
+ "label": "open-resize@900",
+ "button": 211.8,
+ "list": 312,
+ "left": -2,
+ "right": 98.3,
+ "cut": []
+ },
+ {
+ "label": "open-resize@600",
+ "button": 149.5,
+ "list": 312,
+ "left": -2,
+ "right": 160.5,
+ "cut": []
+ }
+ ]
+ }
+ },
+ {
+ "name": "sweep: no overflow at any width (400–1440)",
+ "result": "PASS",
+ "detail": []
+ },
+ {
+ "name": "sweep: icons switch together and always before More",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "sweep: narrower never shows more (monotonic: full → icons → More, fewer items)",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "sweep: growing back gives the same state at every width (reverse order, no stuck item)",
+ "result": "PASS",
+ "detail": ""
+ },
+ {
+ "name": "sweep: 1440 full; each stage reached by measured room only",
+ "result": "PASS",
+ "detail": {
+ "icons": 1300,
+ "more": 910
+ }
+ },
+ {
+ "name": "sweep: shorter file name moves the steps to narrower widths",
+ "result": "PASS",
+ "detail": {
+ "long": {
+ "icons": 1300,
+ "more": 910
+ },
+ "short": {
+ "icons": 1080,
+ "more": 830
+ }
+ }
+ },
+ {
+ "name": "no page errors",
+ "result": "PASS",
+ "detail": ""
+ }
+ ],
+ "errors": []
+}
\ No newline at end of file
diff --git a/docs/sw/viewer-logo-document-menu-qa/viewer-logo-menu-open.png b/docs/sw/viewer-logo-document-menu-qa/viewer-logo-menu-open.png
new file mode 100644
index 0000000..e43f148
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/viewer-logo-menu-open.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/viewer-logo-with-list-open.png b/docs/sw/viewer-logo-document-menu-qa/viewer-logo-with-list-open.png
new file mode 100644
index 0000000..1a44a39
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/viewer-logo-with-list-open.png differ
diff --git a/docs/sw/viewer-logo-document-menu-qa/viewer-narrow-771.png b/docs/sw/viewer-logo-document-menu-qa/viewer-narrow-771.png
new file mode 100644
index 0000000..f099738
Binary files /dev/null and b/docs/sw/viewer-logo-document-menu-qa/viewer-narrow-771.png differ
diff --git a/packages/sw/ai/COMPONENT_CATALOG.md b/packages/sw/ai/COMPONENT_CATALOG.md
index b334020..246eecf 100644
--- a/packages/sw/ai/COMPONENT_CATALOG.md
+++ b/packages/sw/ai/COMPONENT_CATALOG.md
@@ -137,7 +137,7 @@ SW **뷰어 모드** 전용 Export입니다(2026-10-19 추가, 참고 DWG/DXF
| 컴포넌트 | 용도 | 주요 Props | 검증 |
|---|---|---|---|
-| `SWViewerHeader` | 52px 뷰어 Header(로고 면 → 파일명 면 → 메뉴 그룹 → 오른쪽 끝), 반응형 More | `groups: SWViewerHeaderGroup[]`(필수), `onCommand(SWViewerCommand)`, `documentTitle`(`string \| null`), `documentPlaceholder`, `documentTooltip`, `document`, `logo`, `logoSrc`, `logoAlt`, `onHomeClick`, `trailing`, `moreLabel`, `toolbarLabel`, `` 속성·`ref` | ◻ |
+| `SWViewerHeader` | 52px 뷰어 Header(로고 면 → 파일명 면 → 메뉴 그룹 → 오른쪽 끝), 반응형 More. 로고 클릭 = 파일 열기 메뉴(`SWHeader` `...` 메뉴 모양), 파일명 클릭 = 문서 목록(`SWHeaderDocumentTitle`, + New 없음, `onDocumentOpen`이 있으면 아래 Open), 두 메뉴는 동시에 열리지 않음 | `groups: SWViewerHeaderGroup[]`(필수), `onCommand(SWViewerCommand)`, `documentTitle`(`string \| null`), `documentPlaceholder`, `documentTooltip`, `document`, `logoMenu`(`{ title?, items: { id, label, icon?, value?, checked?, disabled?, hidden?, onSelect? }[] }`, 기본 없음), `documents`(`SWHeaderDocument[]`), `activeDocumentId`, `onDocumentSelect`, `onDocumentOpen`(있을 때만 목록 아래 Open), `documentOpenLabel`(기본 `Open`), `logo`, `logoSrc`, `logoAlt`, `onHomeClick`, `trailing`, `moreLabel`, `toolbarLabel`, `` 속성·`ref` | ◻ |
| `SWViewerHeaderButton` | `trailing`·`custom` 항목용 Header 버튼(정보 버튼 등) | `label`(필수), `icon`, `iconOnly`(`true` = 32 × 40, `"compact"` = 34 × 34), `pressed`(주면 토글), `button` 속성 | ◻ |
| `SWViewerMenu` | 단독 Dropdown·우클릭 메뉴(Header `type: "menu"`와 같은 메뉴) | `entries: SWViewerMenuEntry[]`, `open`, `onOpenChange`(필수), `anchorRef` 또는 `anchorPoint`, `align`(`start`/`end`), `width`, `surface`(`light` 기본 / `beige` = More 면), `label`, `onEntrySelect`, `initialFocus`(`first`/`menu`), `id`, `className` | ◻ |
| `SWViewerIcon` | 직접 그린 아이콘에 SW 아이콘 외곽선(SVG stroke) 적용 | `children`(필수), `className` | ◻ |
diff --git a/packages/sw/ai/COMPOSITION_PATTERNS.md b/packages/sw/ai/COMPOSITION_PATTERNS.md
index d69728e..5abf79d 100644
--- a/packages/sw/ai/COMPOSITION_PATTERNS.md
+++ b/packages/sw/ai/COMPOSITION_PATTERNS.md
@@ -135,23 +135,22 @@ SW **뷰어 모드**(도면 보기) 화면입니다. 모드 선택은 [AI_USAGE.
```tsx
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: sampleEntries },
- ] },
{ id: "space", label: "도면 공간", surface: "field", hidden: !fileName, items: [
{ type: "tabs", id: "space", label: "모델과 배치 선택", collapseBelow: 1180, value: space, onValueChange: setSpace, options },
] },
{ id: "view", label: "화면 보기", surface: "field", items: [
- { type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, disabled: !fileName },
+ // 실제 가용 폭: 공간이 있으면 표시 → 아이콘만 → More (숫자는 순서만 정함).
+ { type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, collapseBelow: 1180, disabled: !fileName },
{ type: "switch", id: "depth", label: "2.5D", checked: depth, collapseBelow: 1180, onCheckedChange: setDepth },
] },
{ id: "panels", label: "패널", placement: "end", items: [
- { type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: optionsOpen, collapseBelow: 900, onPressedChange: setOptionsOpen },
+ { type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: optionsOpen, iconOnlyBelow: 900, onPressedChange: setOptionsOpen },
] },
]
setAbout(true)} />
@@ -171,12 +170,13 @@ const groups: SWViewerHeaderGroup[] = [
| 구성 | 사용 |
|---|---|
-| Header 메뉴 | `groups` 데이터: 파일(`button`·`menu`), 도면 공간(`tabs`, `surface: "field"`), 화면 보기(`button`·`switch`, `field`), 패널 토글(`toggle`, `placement: "end"`) |
+| 파일 열기·샘플 | 로고 오른쪽 파일 열기 버튼 `onDocumentOpen`·`documentOpenLabel`(`SWHeader` `documentOpenPlacement="header"` 면 + 테두리 아이콘, 공간이 부족하면 아이콘만, More로 가지 않음), 파일명 클릭 `documents`·`activeDocumentId`·`onDocumentSelect`(`SWHeader` 파일명 목록, Open·+ New 없음) |
+| Header 메뉴 | `groups` 데이터: 도면 공간(`tabs`, `surface: "field"`), 화면 보기(`button`·`switch`, `field`), 패널 토글(`toggle`, `placement: "end"`) |
| 오른쪽 끝 | `trailing`: `SWViewerStatus` + `SWViewerHeaderButton iconOnly` |
| 도구 창 | 비모달 `SWViewerPanel`(드래그·크기 조절·접기 기본). 여러 개면 `inset`·`defaultPosition`으로 겹치지 않게 |
| 확인·정보 | `SWViewerPanel modal` + `footer`의 `SWViewerButton variant="primary"` |
| 패널 내용 | `SWViewerSection` > `SWViewerField` > `SWViewerSwitch` / `SWViewerSlider` / `SWViewerInput`, 목록은 `surface="plain"` + 프로그램 목록, 설명 `SWViewerHint` |
-| 반응형 | 참고값 `collapseBelow` 1180(샘플·도면 공간·2.5D) / 900(패널 토글), `iconOnlyBelow` 560(파일 열기·전체 보기) |
+| 반응형 | 고정 너비가 아니라 Header의 실제 가용 폭으로 3단계: 기본(아이콘+텍스트) → `iconOnlyBelow` 항목 모두 아이콘만(툴팁 유지) → `collapseBelow` 항목을 하나씩 More로(값이 큰 것, 같으면 오른쪽 먼저). 넓어지면 역순 복귀. 패널 토글은 `collapseBelow` 없이 아이콘까지만 |
| 우클릭 메뉴 | `SWViewerMenu` + `anchorPoint` |
- 상태(파일명·패널 열림·탭 값)는 프로그램이 가지고, 명령은 각 Callback 또는 `onCommand` 한 곳에서 받는다. 도면 영역 높이·렌더러 크기는 앱 레이아웃 담당(VIEWER.md §7).
diff --git a/packages/sw/ai/VIEWER.md b/packages/sw/ai/VIEWER.md
index 3e8da58..15c0ce3 100644
--- a/packages/sw/ai/VIEWER.md
+++ b/packages/sw/ai/VIEWER.md
@@ -36,12 +36,16 @@
| `type: "custom"` | `render()`, `menuEntries?`(More에 넣을 항목) |
| `onCommand(command)` | 모든 명령을 한 곳에서 받습니다: `{ id, itemId, value? }`. 항목 자체 Callback 다음에 호출 |
| `documentTitle`, `documentPlaceholder`, `documentTooltip`, `document?` | 파일명 면 |
+| `documents?: SWHeaderDocument[]`, `activeDocumentId?`, `onDocumentSelect?(document)` | 파일명 클릭 시 아래 문서 목록(샘플 도면 등). `SWHeader` 파일명 목록(`SWHeaderDocumentTitle`)과 같은 모양·키보드(ArrowDown 열기, Escape, 바깥 클릭)이며 **Open·+ New는 표시하지 않음**. 목록 외곽 너비는 파일명 버튼 외곽 너비와 `SWHeader` 목록 너비(312px) 이상이며 긴 항목이면 넓어집니다. 열려도 로고가 위에 표시됨. 선택 시 프로그램이 파일을 불러오고 `documentTitle`·`activeDocumentId`를 갱신. `document`가 있으면 `document`가 우선 |
+| `onDocumentOpen?()`, `documentOpenLabel?` | 로고 오른쪽 파일 열기 버튼(`SWHeader` `documentOpenPlacement="header"`와 같은 파일명 면 `SWToolButton appearance="document"` + `SWHeader` `...` 버튼 테두리의 폴더 아이콘 + `documentOpenLabel`, 기본 `Open`). 공간이 부족하면 아이콘만(라벨은 Tooltip·접근성 이름) 표시하고 More로 옮기지 않습니다. 실행 후 `onCommand({ id: "open", itemId: "document" })` |
+| `logoMenu?: { title?, items }` | (2026-10-21 이후 뷰어 화면에서 사용하지 않음, 호환용) 로고 클릭 메뉴. 파일 열기는 `onDocumentOpen` 버튼을 씁니다. 항목 `{ id, label, icon?, value?, checked?, disabled?, hidden?, onSelect? }` |
| `logo?`, `logoSrc?`, `logoAlt?`, `onHomeClick?` | `SWHeader`와 같은 로고 Props. **주지 않으면 아무것도 그리지 않습니다**(“HM” 문자·기본 아이콘 없음, 로고 면은 사선 끝 24px만). 주면 72px 면(로고가 더 넓으면 그만큼), 높이 최대 24px |
| `trailing?: ReactNode` | 오른쪽 끝 자유 영역: `SWViewerStatus`, `SWViewerHeaderButton` |
| `moreLabel?`, `toolbarLabel?`, `` 속성, `ref` | |
-- **반응형(참고 뷰어와 동일):** 항목별 `collapseBelow`로 More(⋯, 베이지 260px Dropdown)로 이동합니다. 참고 뷰어 기준값: 샘플·도면 공간·2.5D·어두운 배경 `1180`, 패널 토글 `900`, 파일 열기·전체 보기 `iconOnlyBelow: 560`. 1180px 미만은 간격 2px, 560px 미만은 상태 글자 숨김(CSS container). 가로 스크롤은 만들지 않습니다.
+- **반응형(2026-10-21, 실제 가용 폭 기준 3단계):** 고정 Breakpoint를 쓰지 않고 Header 행의 실제 남는 폭(로고·파일명·메뉴·Gap·Padding·More 버튼 포함, 파일명은 글자 폭까지만)을 측정합니다. ① 모두 들어가면 기본 형태(아이콘+텍스트), ② 부족하면 `icon`과 `iconOnlyBelow`가 있는 항목을 모두 아이콘만(라벨은 Tooltip·접근성 이름), ③ 그래도 부족하면 `collapseBelow` 항목을 하나씩 More(⋯, 베이지 260px Dropdown)로 옮깁니다(값이 큰 것부터, 같으면 오른쪽 먼저). 넓어지면 More → 아이콘 → 기본 순서로 복귀하고, 상단과 More에 같은 항목이 동시에 나오지 않습니다. `collapseBelow`·`iconOnlyBelow`의 숫자는 순서만 정합니다(Props 형식 동일). 파일 열기는 `logoMenu`, 샘플은 `documents`. 레이어·설정·속성은 `collapseBelow` 없이 아이콘까지만. 1180px 미만 간격 2px, 560px 미만 상태 글자 숨김(CSS container)은 그대로입니다. 가로 스크롤은 만들지 않습니다.
- More는 그룹/항목 단위 구획으로 자동 구성됩니다: menu·tabs는 자기 구획(`moreLabel`), button·toggle·switch는 그룹 구획(`group.moreLabel ?? group.label`). toggle·switch는 체크 항목, tabs는 라디오 항목이 됩니다.
+- **Dropdown 색(2026-10-21):** Header 안의 Dropdown — 메뉴 버튼(`type: "menu"`), More(⋯), 로고 메뉴 — 은 크기·간격·글자는 `SWViewerMenu` 그대로, 색은 일반 `SWHeader`의 `...` 메뉴와 같습니다(면 #0e3d2f, Border `--hm-sw-header-object-border-color`, 그림자 0 4px 12px rgba(0,0,0,.5), 글자 `--hm-sw-header-text-color`, 보조 글자는 같은 색 80%, Hover `--hm-sw-header-hover`, 구분선 `--hm-sw-header-document-menu-divider-color`). 파일명 목록(`documents`)은 `SWHeader` 파일명 목록 모양을 유지하고, 단독 `SWViewerMenu`(우클릭 메뉴 등)는 기존 색 그대로입니다.
- **상태 표현:** 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), 아이콘 `SWViewerIcon`(SWHeader 아이콘 자산의 1px 검정 바깥 Stroke를 **SVG stroke**로 재현: 아이콘 뒤 복사본의 모든 도형을 검정, 원래 선 두께 + 화면 기준 양쪽 1px(`--hm-sw-viewer-icon-outline-width`)로 그림. 위 아이콘은 그대로라 색·모양·선 두께 유지. CSS `filter` 미사용, glow는 생략). Header 항목·`SWViewerHeaderButton`의 `icon`은 자동 적용, `custom` / `trailing`의 직접 그린 아이콘은 ``으로 감쌉니다. 색·크기·굵기·간격은 그대로이고 Hover/Active는 채움 색만 바뀝니다. Dropdown·패널 안에는 외곽선이 없습니다(SWHeader More 메뉴와 같음). 프로그램 아이콘은 `currentColor`로 그리면 상태 색과 외곽선이 자동 적용됩니다.
@@ -82,9 +86,11 @@ import "@hm-design/sw/styles.css"
const groups: SWViewerHeaderGroup[] = [
{ id: "view", label: "화면 보기", surface: "field", items: [{ type: "button", id: "fit", label: "전체 보기", icon: , onSelect: fit }] },
- { id: "panels", label: "패널", placement: "end", items: [{ type: "toggle", id: "options", label: "설정", icon: , pressed: open, onPressedChange: setOpen, collapseBelow: 900 }] },
+ { id: "panels", label: "패널", placement: "end", items: [{ type: "toggle", id: "options", label: "설정", icon: , pressed: open, onPressedChange: setOpen, iconOnlyBelow: 900 }] },
]
-
+ setOpen(false)}>
{id => }
@@ -97,7 +103,7 @@ const groups: SWViewerHeaderGroup[] = [
| 하지 말 것 | 이유 / 대신 |
|---|---|
| ``처럼 로고가 없다고 임의 문자·아이콘 넣기 | 로고는 프로그램이 정합니다. 없으면 생략 |
-| 뷰어 화면에 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`, `SWHeader homeMenu`) 넣기 또는 비슷한 2단 메뉴 직접 만들기 | 홈 메뉴는 SW 일반 모드 전용입니다. 뷰어 로고 클릭은 `onHomeClick`, 파일 열기·샘플은 `groups`의 `button`·`menu` 항목 |
+| 뷰어 화면에 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`, `SWHeader homeMenu`) 넣기 또는 비슷한 2단 메뉴 직접 만들기 | 홈 메뉴는 SW 일반 모드 전용입니다. 뷰어 로고의 파일 열기 메뉴는 `logoMenu`, 샘플 목록은 `documents`(직접 만든 Dropdown 금지) |
| 뷰어 Header를 `div` + `button`으로 다시 만들기, `hm-sw-viewer-*` class를 소비 코드에 붙이기 | 데이터(`groups`)와 `custom` / `trailing`으로 구성 |
| Header 버튼 글자에 `-webkit-text-stroke`·`border`로 외곽선 만들기 | 외곽선은 패키지 토큰이 이미 적용합니다 |
| 메뉴 색·간격을 `#123456`, `margin` 등 임의 값으로 덮기 | 토큰 `--hm-sw-viewer-*` / `--hm-sw-header-*`만 가장 바깥 SW 범위(`` 또는 단독 컴포넌트 루트)의 `style`로 조정(기본값 유지 권장, [AI_USAGE.md](./AI_USAGE.md) §3.1) |
diff --git a/packages/sw/ai/examples/ExampleH-ViewerMode.tsx b/packages/sw/ai/examples/ExampleH-ViewerMode.tsx
index 0fadf01..334e813 100644
--- a/packages/sw/ai/examples/ExampleH-ViewerMode.tsx
+++ b/packages/sw/ai/examples/ExampleH-ViewerMode.tsx
@@ -12,7 +12,6 @@ const Icon = ({ d }: { d: string }) => (
)
const icons = {
- open: ,
fit: ,
layers: ,
options: ,
@@ -28,25 +27,23 @@ export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => vo
const [zoomSpeed, setZoomSpeed] = useState(1)
const [about, setAbout] = useState(false)
+ // File open: the header button after the logo (icon + label, icon only when the header is narrow). File name click:
+ // the sample list only (no Open / + New).
+ const openFile = () => onEvent?.("file:open")
+ const samples = [{ id: "sample-a", name: "BasicSample.dwg", title: "기본 도면 · DWG" }, { id: "sample-b", name: "simple.dxf", title: "기본 도형 · DXF" }]
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 },
+ // Measured steps: full while it fits, icon only next, then into More (the numbers only give the order).
+ { type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, collapseBelow: 1180, 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 }) },
+ { type: "toggle", id: "layers", label: "레이어", icon: icons.layers, pressed: panels.layers, iconOnlyBelow: 900, onPressedChange: layers => setPanels({ ...panels, layers }) },
+ { type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: panels.options, iconOnlyBelow: 900, onPressedChange: options => setPanels({ ...panels, options }) },
] },
]
@@ -54,6 +51,7 @@ export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => vo
<>
{/* No logo prop: nothing is drawn on the cap (no "HM" text). Pass logoSrc / logo to show the program's logo. */}
sample.name === fileName)?.id} onDocumentSelect={sample => setFileName(sample.name)} onDocumentOpen={openFile} documentOpenLabel="파일 열기"
onCommand={command => onEvent?.(`${command.itemId}/${command.id}${command.value === undefined ? "" : `=${String(command.value)}`}`)}
trailing={<>
diff --git a/packages/sw/src/components/sw-header/components/SWHeaderMore.tsx b/packages/sw/src/components/sw-header/components/SWHeaderMore.tsx
index 887b23f..897564f 100644
--- a/packages/sw/src/components/sw-header/components/SWHeaderMore.tsx
+++ b/packages/sw/src/components/sw-header/components/SWHeaderMore.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"
+import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"
import { useOutsidePointerDown } from "../../shared/use-outside-pointer"
import { SWHeaderKGuidePreview } from "./SWHeaderKGuide"
@@ -6,6 +6,8 @@ export interface SWHeaderMoreItem {
visible?: boolean
id: string
label: string
+ /** 16px icon left of the label (SWViewerHeader logo menu, 2026-10-21). SWHeader's own items have none. */
+ icon?: ReactNode
/** Current value shown at the right (e.g. the line style name). */
value?: string
/**
@@ -24,29 +26,73 @@ export interface SWHeaderMoreItem {
guide?: boolean
}
+/** Props of a replacement trigger (`renderTrigger`): spread them on one button. */
+export interface SWHeaderMoreTriggerProps {
+ ref: RefObject
+ "aria-haspopup": "menu"
+ "aria-expanded": boolean
+ "aria-controls"?: string
+ onClick: () => void
+}
+
export interface SWHeaderMoreProps {
items: SWHeaderMoreItem[]
/** Shows the trigger at any width (an item moved in by measurement, not by a breakpoint). */
forceVisible?: boolean
+ /**
+ * Internal reuse of the same menu with another trigger (SWViewerHeader logo menu, 2026-10-21). Default: the
+ * `...` button. With it, ArrowDown on the closed trigger opens the menu on its first item and a selected item
+ * returns focus to the trigger; the `...` button keeps its existing behavior.
+ */
+ renderTrigger?: (props: SWHeaderMoreTriggerProps) => ReactNode
+ /** Title above the items, shown only when at least one item follows. Title and items are one group: no divider. */
+ title?: string
+ /** Accessible name of the menu when there is no `title`. Default `추가 도구`. */
+ label?: string
+ /** Controlled open state (menus that must not be open together). Leave undefined to let the component manage it. */
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ /** Menu edge aligned to the trigger. Default `end` (the `...` button at the right). */
+ align?: "start" | "end"
+ className?: string
}
/** Overflow menu that keeps every moved control reachable. */
-export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
- const [open, setOpen] = useState(false)
+export function SWHeaderMore({ items, forceVisible, renderTrigger, title, label = "추가 도구", open: openProp, onOpenChange, align = "end", className }: SWHeaderMoreProps) {
+ const [internalOpen, setInternalOpen] = useState(false)
+ const open = openProp ?? internalOpen
+ const setOpen = (next: boolean) => {
+ if (openProp === undefined) setInternalOpen(next)
+ onOpenChange?.(next)
+ }
const rootRef = useRef(null)
const triggerRef = useRef(null)
const menuId = useId()
const guideId = useId()
const guideRef = useRef(null)
+ const titleId = useId()
const [guideOpen, setGuideOpen] = useState(false)
+ // Set when the keyboard opens a replacement trigger, so the first item gets focus once the menu renders.
+ const focusFirstRef = useRef(false)
useEffect(() => { if (!open) setGuideOpen(false) }, [open])
useOutsidePointerDown(open, rootRef, () => setOpen(false))
+ useEffect(() => {
+ if (open && focusFirstRef.current) {
+ focusFirstRef.current = false
+ visibleItems()[0]?.focus()
+ }
+ })
+
const visibleItems = () => Array.from(rootRef.current?.querySelectorAll('[role^="menuitem"]:not(:disabled)') ?? []).filter(item => item.offsetParent !== null)
const onKeyDown = (event: KeyboardEvent) => {
- if (event.key === "Escape" && open) {
+ if (renderTrigger && event.key === "ArrowDown" && !open && event.target === triggerRef.current) {
+ event.preventDefault()
+ focusFirstRef.current = true
+ setOpen(true)
+ } else if (event.key === "Escape" && open) {
event.stopPropagation()
setOpen(false)
triggerRef.current?.focus()
@@ -59,9 +105,18 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
}
}
+ const triggerProps: SWHeaderMoreTriggerProps = {
+ ref: triggerRef,
+ "aria-haspopup": "menu",
+ "aria-expanded": open,
+ "aria-controls": open ? menuId : undefined,
+ onClick: () => setOpen(!open),
+ }
+ const titled = !!title && items.some(item => item.visible !== false)
+
return (
-
setGuideOpen(false)}>
- }
{open && (
-
+
+ {/* Title and its items are one group: no divider between them (dividers only separate groups). */}
+ {titled &&
{title}
}
{items.map(item => (
{
item.onSelect?.()
setOpen(false)
+ if (renderTrigger) triggerRef.current?.focus()
}}
>
+ {item.icon && {item.icon}}
{item.label}
{item.value && {item.value}}
diff --git a/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx b/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx
index 3179b83..1b40360 100644
--- a/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx
+++ b/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx
@@ -1,5 +1,9 @@
-import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
+import { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import { moveRovingFocus } from "../shared/roving-focus"
+import { SWHeaderDocumentTitle } from "../sw-header/components/SWHeaderDocumentTitle"
+import { SWHeaderMore } from "../sw-header/components/SWHeaderMore"
+import type { SWHeaderDocument } from "../sw-header/types"
+import { SWToolButton } from "../sw-tool/SWTool"
import { SWViewerMenu } from "./SWViewerMenu"
import { SWViewerSwitch } from "./SWViewerControls"
import { SWViewerIcon } from "./SWViewerIcon"
@@ -25,8 +29,42 @@ export interface SWViewerHeaderProps extends Omit, "
documentPlaceholder?: string
/** Tooltip of the file name. Default: the name, or `파일 열기 또는 샘플을 선택하세요`. */
documentTooltip?: string
- /** Replaces the file-name text (the dark document well stays). */
+ /** Replaces the file-name text (the dark document well stays). Takes precedence over `documents`. */
document?: ReactNode
+ /**
+ * Logo menu (2026-10-21): the logo cap opens a file menu flush under the header (SWHeaderMore behavior: Escape,
+ * outside click, arrow keys; ArrowDown opens it on its first item and focus returns to the logo) with the face, colors,
+ * size, spacing and type of the other viewer header dropdowns (the More face). `title` adds a
+ * title row directly above the items (title + items are one group: no divider), shown only when an item follows;
+ * without a title only the items are listed. `icon`: 16px icon left of the label (SWViewerMenu icon slot).
+ * Each item runs its `onSelect`, then `onCommand` with `{ id, itemId: id }`. Takes precedence over `onHomeClick`;
+ * with no visible item the cap keeps the plain logo / `onHomeClick`. Never open together with the document list.
+ */
+ logoMenu?: {
+ title?: string
+ items: { id: string; label: string; icon?: ReactNode; value?: string; checked?: boolean; disabled?: boolean; hidden?: boolean; onSelect?: () => void }[]
+ }
+ /**
+ * Document list below the file name (2026-10-21): the SWHeader file-name button and dropdown (SWHeaderDocumentTitle)
+ * without + New and Open (file open is the header button, `onDocumentOpen`). The button shows `documentTitle` (or `documentPlaceholder`); the list
+ * is at least as wide as the file-name button and the SWHeader list, wider for longer rows. Omit to keep the plain
+ * file name.
+ */
+ documents?: SWHeaderDocument[]
+ /** Id of the current document in `documents` (its name in #FFC600; the list order never changes). */
+ activeDocumentId?: string
+ /** A document row was chosen. The host loads it and updates `documentTitle` / `activeDocumentId`. */
+ onDocumentSelect?: (document: SWHeaderDocument) => void
+ /**
+ * File open button in the header (2026-10-21), like SWHeader `documentOpenPlacement="header"`: the SWHeader file-name
+ * surface (SWToolButton `appearance="document"`) right after the logo, with a bordered folder icon and the label.
+ * Shown only with this callback; the document list never shows Open or + New in the viewer. When the header has no
+ * room it shows the icon only (label as tooltip and accessible name, measured like the other items), never in More.
+ * Runs the callback, then `onCommand` with `{ id: "open", itemId: "document" }`.
+ */
+ onDocumentOpen?: () => void
+ /** Label of that button. Default `Open`. */
+ documentOpenLabel?: string
/** Menu data, in order. Groups with `placement="end"` form the trailing action area. */
groups: SWViewerHeaderGroup[]
/** Free content after the trailing groups (status, info button, ...). */
@@ -42,20 +80,64 @@ export interface SWViewerHeaderProps extends Omit, "
const MoreIcon = () => (
)
+const FolderIcon = () => (
+
+)
const ChevronDown = () => (
)
-const isShown = (item: { hidden?: boolean; collapseBelow?: number }, width: number) => !item.hidden && !(item.collapseBelow !== undefined && width < item.collapseBelow)
+/** Step id of the header Open button (icon only in stage 2). */
+const OPEN_ID = ":document-open"
+
+const isShown = (item: { id: string; hidden?: boolean }, collapsed: ReadonlySet) => !item.hidden && !collapsed.has(item.id)
+
+/**
+ * Measured responsive steps (2026-10-21), applied one at a time while the header has no room left and undone in reverse
+ * order once the room a step freed is back: 1. every item with an `icon` and `iconOnlyBelow` shows its icon only (label
+ * kept as tooltip and accessible name); 2. `collapseBelow` items move into More one by one, the higher value first and,
+ * for the same value, the rightmost first. No fixed width decides a step: the values only give the order.
+ */
+type Step = { kind: "icons"; ids: string[] } | { kind: "collapse"; id: string }
+function responsiveSteps(groups: SWViewerHeaderGroup[], extraIcons: string[] = []): Step[] {
+ const icons: string[] = [...extraIcons], collapse: { id: string; rank: number; order: number }[] = []
+ groups.filter(group => !group.hidden).flatMap(group => group.items).forEach((item, order) => {
+ if (item.hidden) return
+ if (item.type !== "custom" && item.icon && !item.iconOnly && item.iconOnlyBelow !== undefined) icons.push(item.id)
+ if (item.collapseBelow !== undefined) collapse.push({ id: item.id, rank: item.collapseBelow, order })
+ })
+ collapse.sort((a, b) => b.rank - a.rank || b.order - a.order)
+ return [...(icons.length ? [{ kind: "icons" as const, ids: icons }] : []), ...collapse.map(item => ({ kind: "collapse" as const, id: item.id }))]
+}
+
+/**
+ * Room left in the header row (negative: missing) with every part at its natural width: the file name at its text
+ * width up to its usual width (so a short name leaves room to the menus), the object surface without stretching and
+ * the trailing area without its auto margin (`data-measuring`, sw-viewer.css). Read and reset in one layout effect,
+ * never painted; positioned layers (menus, logo menu) never count. Same method as SWHeader's measured More.
+ */
+function measureRoom(root: HTMLElement) {
+ root.setAttribute("data-measuring", "")
+ const style = getComputedStyle(root), box = root.getBoundingClientRect()
+ const end = box.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight)
+ let used = box.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft)
+ for (const child of Array.from(root.children)) {
+ const childStyle = getComputedStyle(child)
+ if (childStyle.display === "none" || childStyle.position === "absolute" || childStyle.position === "fixed") continue
+ used = Math.max(used, child.getBoundingClientRect().right + parseFloat(childStyle.marginRight))
+ }
+ root.removeAttribute("data-measuring")
+ return end - used
+}
/** 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): SWViewerMenuEntry[] {
+function moreEntries(groups: SWViewerHeaderGroup[], collapsedIds: ReadonlySet, command: (c: SWViewerCommand) => void, owners: Map): 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)
+ const collapsed = group.items.filter(item => !item.hidden && collapsedIds.has(item.id))
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) {
@@ -103,7 +185,8 @@ function MenuButton({ item, iconOnly, onCommand }: { item: Extract{item.label}}
- onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} />
>
)
@@ -143,15 +226,15 @@ function SwitchField({ item, onCommand }: { item: Extract void }) {
+function Item({ item, compact, onCommand }: { item: SWViewerHeaderItem; compact: boolean; onCommand: (c: SWViewerCommand) => void }) {
if (item.type === "custom") return <>{item.render()}>
- const iconOnly = !!item.icon && (item.iconOnly ? "always" : item.iconOnlyBelow !== undefined && width < item.iconOnlyBelow)
+ const iconOnly = !!item.icon && (item.iconOnly ? "always" : compact)
if (item.type === "menu") return
if (item.type === "tabs") return
if (item.type === "switch") return
const toggle = item.type === "toggle"
return (
- {
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 }) }
@@ -192,19 +275,23 @@ export const SWViewerHeaderButton = forwardRef(function SWViewerHeader(
{
logo, logoSrc, logoAlt, onHomeClick, documentTitle, documentPlaceholder = "열린 도면 없음", documentTooltip, document: documentNode,
+ logoMenu, documents, activeDocumentId, onDocumentSelect, onDocumentOpen, documentOpenLabel,
groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest
},
forwardedRef,
) {
const rootRef = useRef(null)
- const [width, setWidth] = useState(1920)
+ // Header width: a resize re-renders, and the layout pass below measures again.
+ const [, setWidth] = useState(1920)
const gradientId = `hm-sw-viewer-brand-${useId().replace(/:/g, "")}`
useLayoutEffect(() => {
const root = rootRef.current
@@ -215,23 +302,73 @@ export const SWViewerHeader = forwardRef(funct
observer.observe(root)
return () => observer.disconnect()
}, [])
+ const start = groups.filter(group => group.placement !== "end")
+ const end = groups.filter(group => group.placement === "end")
+ // Measured responsive steps (see responsiveSteps): `level` steps are applied. Each layout pass measures the room; a
+ // missing room applies the next step, and the last step is undone once the room it freed (measured right after it was
+ // applied) is there again. The passes are synchronous layout effects, so only the settled layout is painted.
+ const steps = responsiveSteps([...start, ...end], onDocumentOpen ? [OPEN_ID] : [])
+ const [level, setLevel] = useState(0)
+ const applied = steps.slice(0, level)
+ const collapsed = new Set(applied.flatMap(step => (step.kind === "collapse" ? [step.id] : [])))
+ const compact = new Set(applied.flatMap(step => (step.kind === "icons" ? step.ids : [])))
+ const freedRef = useRef<{ before: number; room?: number }[]>([])
+ // Steps or texts that change widths start the measurement over (file name, labels, items shown).
+ const layoutKey = JSON.stringify([documentTitle, documentPlaceholder, documentOpenLabel, !!onDocumentOpen, steps, groups.map(group => [group.hidden, group.items.map(item => [item.id, item.hidden, "label" in item ? item.label : ""])])])
+ const keyRef = useRef(layoutKey)
+ // Web fonts change text widths without a resize: measure again once they are loaded.
+ const [, setFontsLoaded] = useState(0)
+ useEffect(() => {
+ const fonts = typeof document === "undefined" ? undefined : document.fonts
+ if (!fonts) return
+ let live = true
+ const loaded = () => { if (live) setFontsLoaded(count => count + 1) }
+ fonts.addEventListener?.("loadingdone", loaded)
+ fonts.ready.then(loaded)
+ return () => { live = false; fonts.removeEventListener?.("loadingdone", loaded) }
+ }, [])
+ useLayoutEffect(() => {
+ const root = rootRef.current
+ if (!root) return
+ if (keyRef.current !== layoutKey) {
+ keyRef.current = layoutKey
+ freedRef.current = []
+ if (level !== 0) return setLevel(0)
+ }
+ if (level > steps.length) return setLevel(steps.length)
+ const room = measureRoom(root)
+ const freed = freedRef.current
+ const last = level > 0 ? freed[level - 1] : undefined
+ if (last && last.room === undefined) last.room = room - last.before
+ if (room < -0.5) {
+ if (level < steps.length) {
+ freed[level] = { before: room }
+ setLevel(level + 1)
+ }
+ } else if (level > 0 && (last?.room === undefined || room >= last.room)) {
+ freed.length = level - 1
+ setLevel(level - 1)
+ }
+ })
const command = (value: SWViewerCommand) => onCommand?.(value)
const owners = new Map()
- const more = moreEntries(groups, width, command, owners)
+ const more = moreEntries(groups, collapsed, command, owners)
const moreRef = useRef(null)
const [moreOpen, setMoreOpen] = useState(false)
const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu")
+ // The logo menu and the document list are never open together (opening one closes the other).
+ const [openMenu, setOpenMenu] = useState<"logo" | "document" | null>(null)
+ const menuOpenChange = (menu: "logo" | "document") => (next: boolean) => setOpenMenu(current => (next ? menu : current === menu ? null : current))
+ const logoItems = logoMenu?.items.filter(item => !item.hidden) ?? []
const renderGroup = (group: SWViewerHeaderGroup) => {
- const items = group.items.filter(item => isShown(item, width))
+ const items = group.items.filter(item => isShown(item, collapsed))
if (group.hidden || !items.length) return null
return (
+ ))}
),
}
@@ -393,6 +396,8 @@ export const Responsive: Story = {
export const Parts: Story = {
name: "개별 부품 (조합용)",
+ // 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
+ tags: ["verification"],
render: () => (
diff --git a/src/sw/sw-header-dropdown.stories.tsx b/src/sw/sw-header-dropdown.stories.tsx
index 9e45307..5951717 100644
--- a/src/sw/sw-header-dropdown.stories.tsx
+++ b/src/sw/sw-header-dropdown.stories.tsx
@@ -12,5 +12,7 @@ export default meta
export const ComposedControls: StoryObj = {
name: "Dropdown",
+ // 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
+ tags: ["verification"],
render: () => ,
}
diff --git a/src/sw/sw-header-home-menu.stories.tsx b/src/sw/sw-header-home-menu.stories.tsx
index d137436..393981d 100644
--- a/src/sw/sw-header-home-menu.stories.tsx
+++ b/src/sw/sw-header-home-menu.stories.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useRef, useState, type ReactNode } from "react"
+import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
// Public API only, like a consuming project.
import {
@@ -10,14 +10,14 @@ import "@hm-design/sw/styles.css"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import "./sw-home-menu.css"
import {
- figmaHomeMenuItemIds, figmaHomeMenuSections, figmaRecentFiles, figmaTileNodes, figmaTitleNodes, withRecentFiles,
+ figmaHomeMenuItemIds, figmaHomeMenuSections, figmaRecentFiles, withRecentFiles,
} from "./sw-home-menu-fixture"
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
import figmaPropertyBar from "./assets/home-menu/figma-26-10492.png"
// Owner: docs/sw/figma-additional/analysis-report.md (Figma rj6wpsfk24lOWvWiVRAANP 26:10591 / 662:67267).
// 2026-10-19: the former SW/Home Menu Stories live under SW/Header (SW normal mode only; SW/Viewer has no logo menu).
-// "Home Menu" and "Home Menu · 단독 사용 · 조합형" are the sidebar Stories; the other Stories keep their checks reachable
+// "Home Menu" is the sidebar Story (단독 사용 hidden 2026-10-21); the other Stories keep their checks reachable
// by URL (verification tag). 2026-10-20: Figma screenshot comparisons that no test or document used were removed;
// the Figma originals stay in docs/sw/figma-additional/screenshots/.
const KICKER = "HM DESIGN SYSTEM / SW"
@@ -85,42 +85,6 @@ function HeaderWithMenu({ width, defaultOpen = false, defaultActiveIds = [], acc
)
}
-/**
- * Header width set by a range input (Storybook control only): the header measures its own width. The range ends at
- * the room the page gives (no page scroll), so the browser width and the slider both drive it.
- */
-function ResizableHeaderWithMenu() {
- const [width, setWidth] = useState(1280)
- const [room, setRoom] = useState(1920)
- const observed = useRef(null)
- const measureRef = useCallback((element: HTMLDivElement | null) => {
- observed.current?.disconnect()
- if (!element) return
- observed.current = new ResizeObserver(() => setRoom(Math.floor(element.clientWidth)))
- observed.current.observe(element)
- }, [])
- const shown = Math.min(width, room)
- return (
- <>
-
-
-)
-
/** The sidebar Story: the logo menu of SWHeader (SW normal mode) with its Figma states, sub panel and Escape stack. */
export const HomeMenu: Story = {
name: "Home Menu",
@@ -131,16 +95,12 @@ export const HomeMenu: Story = {
{log => log}
-
-
-
- {tileTable}
{log => log}
@@ -254,6 +214,8 @@ function StandaloneDemo() {
export const HomeMenuStandalone: Story = {
name: "Home Menu · 단독 사용 · 조합형",
+ // 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
+ tags: ["verification"],
render: () => (
}
+export const DocumentOpen: Story = { name: "Document Open", tags: ["verification"], render: () => }
function HiddenSubControlsPage() {
const [log, record] = useLog()
@@ -137,4 +137,4 @@ function HiddenSubControlsPage() {
)
}
-export const HiddenSubControls: Story = { name: "Hidden Sub Controls", render: () => }
+export const HiddenSubControls: Story = { name: "Hidden Sub Controls", tags: ["verification"], render: () => }
diff --git a/src/sw/sw-header-overview.tsx b/src/sw/sw-header-overview.tsx
index 6ec3a7f..aa16630 100644
--- a/src/sw/sw-header-overview.tsx
+++ b/src/sw/sw-header-overview.tsx
@@ -5,6 +5,7 @@ import {
type SWHomeMenuSectionData,
} from "@hm-design/sw"
import { StoryPage, StoryPageHeader } from "@/components/story-page"
+import { showVerificationControls } from "./sw-story-flags"
type Selection = "none" | "all" | "circle" | "expanded"
interface OverviewProps {
@@ -128,7 +129,7 @@ export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFi
return
-
+ {/* Callback record for the browser tests only; not shown on the page. */}
+
}
diff --git a/src/sw/sw-header.stories.tsx b/src/sw/sw-header.stories.tsx
index 04f7ae8..9285c54 100644
--- a/src/sw/sw-header.stories.tsx
+++ b/src/sw/sw-header.stories.tsx
@@ -6,7 +6,7 @@ import {
type SWHeaderColorMode, type SWHeaderColorOption, type SWHeaderColorValue, type SWHeaderDimensionStyleOption,
type SWHeaderDocument, type SWHeaderLayer, type SWHeaderLineStyleValue, type SWHeaderLineType, type SWHeaderLineWeight,
type SWHeaderMenuId, type SWHeaderProps, type SWHeaderTextStyleOption, type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderPropertyShapeContent,
- SWRecentFiles, SWToolButton,
+ SWRecentFiles,
} from "@hm-design/sw"
import "@hm-design/sw/styles.css"
import { SWHeaderMenuDetail } from "../../packages/sw/src/components/sw-header/components/SWHeaderDropdown"
@@ -185,6 +185,7 @@ function InteractiveHeader(props: Partial) {
const OVERVIEW_HOME_MENU = withRecentFiles(figmaHomeMenuSections, )
export const Overview: Story = {
+ name: "SW Header",
parameters: { controls: { disable: true } },
render: () => (
@@ -554,6 +557,8 @@ function DocumentDemo({ width, ...props }: DocumentHostProps & { width: number }
export const DocumentDropdown: Story = {
name: "Document Dropdown",
+ // 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks.
+ tags: ["verification"],
parameters: { controls: { disable: true } },
render: () => (
@@ -771,79 +776,14 @@ function StyleHeader({ onLog, showState, ...props }: StyleHostProps) {
)
}
-/** Interactive host with a visible callback log (checks that every action calls one callback once). */
-function StyleDemo({ width, ...props }: StyleHostProps & { width: number }) {
- const [log, setLog] = useState([])
- return (
- <>
-
Callback: {log.length ? log.join(" → ") : "없음"}
- setLog(lines => [...lines.slice(-11), line])} />
- >
- )
-}
-
-const MORE_WIDTHS = [1920, 1800, 1799, 1440, 1180, 1179, 1080, 1079, 900, 899, 768, 640, 639, 520, 519, 390]
-
-/**
- * Measured More (2026-10-19): the header width comes from a range input and the host adds content the breakpoints
- * do not know about (buttons next to the logo, extra tools). Parts move into More only while the real room is
- * missing and return to their own place, in their own order, once it is back. Range / checkboxes are Storybook controls.
- */
-function MeasuredMoreDemo() {
- const [width, setWidth] = useState(1920)
- const [leading, setLeading] = useState(true)
- const [tools, setTools] = useState(true)
- const [homeMenu, setHomeMenu] = useState(true)
- const [barOpen, setBarOpen] = useState(false)
- const [panelOpen, setPanelOpen] = useState(false)
- const [log, setLog] = useState([])
- const record = (line: string) => setLog(lines => [...lines.slice(-7), line])
- return (
- <>
-