feat(sw): viewer header keeps every menu down to 520px
Deploy Storybook / deploy (push) Successful in 1m16s

- File name: clamp(80px, min(45cqw - 256px, 100cqw - 780px), 360px),
  56px below 560px; 1280px and wider unchanged
- Model / Layout tabs narrow (name, then count ellipsized) down to the new
  --hm-sw-viewer-tabs-min-width (48px) before anything moves into More;
  the measurement counts them at that minimum once every icon step applies
- Switch items get an optional shortLabel shown below 560px (어두운 배경 ->
  배경); counts hidden there
- More appears only below about 520px; viewer test and AI guides updated
This commit is contained in:
shkim4 committed 2026-10-26 20:00:50 +09:00
1 parent 7976801034
commit cabd2f67de
8 files changed
+83 -34

No files matched your search

+1 -1
View File
@@ -175,7 +175,7 @@ const groups: SWViewerHeaderGroup[] = [
| 도구 창 | 비모달 `SWViewerPanel`(드래그·크기 조절·접기 기본). 여러 개면 `inset`·`defaultPosition`으로 겹치지 않게 | | 도구 창 | 비모달 `SWViewerPanel`(드래그·크기 조절·접기 기본). 여러 개면 `inset`·`defaultPosition`으로 겹치지 않게 |
| 확인·정보 | `SWViewerPanel modal` + `footer`의 `SWViewerButton variant="primary"` | | 확인·정보 | `SWViewerPanel modal` + `footer`의 `SWViewerButton variant="primary"` |
| 패널 내용 | `SWViewerSection` > `SWViewerField` > `SWViewerSwitch` / `SWViewerInput`, 옵션 패널 게이지는 `SWSlider appearance="property"`(스타일 B, Card당 값 하나에 하나, VIEWER.md §4), 목록은 `surface="plain"` + 프로그램 목록, 설명 `SWViewerHint`. **레이어별·전체 표시·숨김은 전구(`SWLayerVisibilityIcon`) 아이콘 버튼(Switch 금지), 검색·전체 레이어 고정 + 목록만 스크롤**(VIEWER.md §4.1, 예제 H) | | 패널 내용 | `SWViewerSection` > `SWViewerField` > `SWViewerSwitch` / `SWViewerInput`, 옵션 패널 게이지는 `SWSlider appearance="property"`(스타일 B, Card당 값 하나에 하나, VIEWER.md §4), 목록은 `surface="plain"` + 프로그램 목록, 설명 `SWViewerHint`. **레이어별·전체 표시·숨김은 전구(`SWLayerVisibilityIcon`) 아이콘 버튼(Switch 금지), 검색·전체 레이어 고정 + 목록만 스크롤**(VIEWER.md §4.1, 예제 H) |
| 반응형 | 고정 너비가 아니라 Header의 실제 가용 폭으로 3단계: 기본(아이콘+텍스트) → `iconOnlyBelow` 항목 모두 아이콘만(툴팁 유지) → `collapseBelow` 항목을 하나씩 More로(값이 큰 것, 같으면 오른쪽 먼저). 넓어지면 역순 복귀. 패널 토글은 `collapseBelow` 없이 아이콘까지만 | | 반응형 | 고정 너비가 아니라 Header의 실제 가용 폭으로 3단계: 기본(아이콘+텍스트) → `iconOnlyBelow` 항목 모두 아이콘만(툴팁 유지) → `collapseBelow` 항목을 하나씩 More로(값이 큰 것, 같으면 오른쪽 먼저). 넓어지면 역순 복귀. 패널 토글은 `collapseBelow` 없이 아이콘까지만. tabs는 More 전에 최소 48px까지 `…`로 좁아지고, 긴 switch 캡션은 `shortLabel`로 560px 미만에서 짧게(2026-10-26) |
| 우클릭 메뉴 | `SWViewerMenu` + `anchorPoint` | | 우클릭 메뉴 | `SWViewerMenu` + `anchorPoint` |
- 상태(파일명·패널 열림·탭 값)는 프로그램이 가지고, 명령은 각 Callback 또는 `onCommand` 한 곳에서 받는다. 도면 영역 높이·렌더러 크기는 앱 레이아웃 담당(VIEWER.md §7). - 상태(파일명·패널 열림·탭 값)는 프로그램이 가지고, 명령은 각 Callback 또는 `onCommand` 한 곳에서 받는다. 도면 영역 높이·렌더러 크기는 앱 레이아웃 담당(VIEWER.md §7).
+1 -1
View File
@@ -145,7 +145,7 @@
|---|---| |---|---|
| Header | `--hm-sw-viewer-height` 52px, `--hm-sw-viewer-background` #0a0a0b(단색, 2026-10-21 — Storybook 뷰어 캔버스 바탕색), `--hm-sw-viewer-border-color` #071e18, `--hm-sw-viewer-text` #fff, `--hm-sw-viewer-muted` #bddfd4, `--hm-sw-viewer-accent` #ffc600, `--hm-sw-viewer-hover` #266651 | | Header | `--hm-sw-viewer-height` 52px, `--hm-sw-viewer-background` #0a0a0b(단색, 2026-10-21 — Storybook 뷰어 캔버스 바탕색), `--hm-sw-viewer-border-color` #071e18, `--hm-sw-viewer-text` #fff, `--hm-sw-viewer-muted` #bddfd4, `--hm-sw-viewer-accent` #ffc600, `--hm-sw-viewer-hover` #266651 |
| 로고 면 | 2026-10-23부터 `SWHeader` 홈과 같은 사선 배경(Figma 에셋 9-slice)·같은 폭 계산(왼쪽 12 + 로고 + 36, 다음 부품과 17px 겹침, 예시 로고에서 79px). `--hm-sw-viewer-brand-width` 기본 `auto`. 로고 다음 첫 부품(파일 열기·파일명) 시작 여백 8px. Hover 효과 없음(기존 그대로, `SWHeader` Hover와 다름). 로고가 없으면 로고 면 없음(폭 0, 첫 부품 12px 안쪽). `--hm-sw-viewer-brand-empty-width` 24px·`--hm-sw-viewer-brand-top` #123328·`-middle` #205241·`-bottom` #071e18은 더 쓰지 않으며 호환용으로만 남음 | | 로고 면 | 2026-10-23부터 `SWHeader` 홈과 같은 사선 배경(Figma 에셋 9-slice)·같은 폭 계산(왼쪽 12 + 로고 + 36, 다음 부품과 17px 겹침, 예시 로고에서 79px). `--hm-sw-viewer-brand-width` 기본 `auto`. 로고 다음 첫 부품(파일 열기·파일명) 시작 여백 8px. Hover 효과 없음(기존 그대로, `SWHeader` Hover와 다름). 로고가 없으면 로고 면 없음(폭 0, 첫 부품 12px 안쪽). `--hm-sw-viewer-brand-empty-width` 24px·`--hm-sw-viewer-brand-top` #123328·`-middle` #205241·`-bottom` #071e18은 더 쓰지 않으며 호환용으로만 남음 |
| 크기·글자 | `--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-tabs-max-width` 378px(tabs만 있는 field 그룹의 최대 폭, 2026-10-23), `--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-gap` 4px, `--hm-sw-viewer-radius` 4px, `--hm-sw-viewer-control-height` 40px, `--hm-sw-viewer-button-height` 34px, `--hm-sw-viewer-tabs-max-width` 378px(tabs만 있는 field 그룹의 최대 폭, 2026-10-23), `--hm-sw-viewer-tabs-min-width` 48px(공간이 부족할 때 More로 옮기기 전 `…`로 좁아지는 최소 폭, 2026-10-26), `--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 |
| Model / Layout 탭(Figma 7652:40344, 2026-10-23) | 컨테이너: `--hm-sw-viewer-tabs-height` = `calc(var(--hm-sw-viewer-control-height) + 2px)` = 42px(옆 field 그룹 바깥 높이와 같음; 바깥 1px은 투명 가장자리(외곽선 제거 후에도 크기 유지용), 그 안 검정 여백 위·좌우 1px·아래 1.5px, 버튼은 나머지 37.5px를 채움), `--hm-sw-viewer-tabs-radius` 6px, `--hm-sw-viewer-tabs-background` `rgba(0, 0, 0, 0.8)`(면), `--hm-sw-viewer-tabs-glow-color` `transparent`(추가 Glow 색, 기본 없음 — 녹색 외곽선은 2026-10-23 사용자 요청으로 제거되어 외곽선 토큰 없음), `--hm-sw-viewer-tabs-glow` `0 0 4px var(--hm-sw-viewer-tabs-glow-color)`, `--hm-sw-viewer-tabs-shadow` `inset 0 2.857px 2.857px rgba(0, 0, 0, 0.25)`(버튼 위에 겹치는 안쪽 그림자). 버튼 공통: 높이는 컨테이너를 채움(별도 높이 토큰 없음), `--hm-sw-viewer-tab-radius` 5px(바깥 모서리만), `--hm-sw-viewer-tab-border-width` 1.428px, `--hm-sw-viewer-tab-gap` 14px(이름–개수; 버튼 좌우 안쪽 여백 5px와 함께 기존 기본 폭·측정 단계 유지). 객체 면의 첫 그룹이 Model / Layout 탭만 가진 경우에만 그 그룹 오른쪽에 `margin-right: 1px`(조건부 규칙, 다른 Header 그룹 간격은 객체 면 gap 그대로): 다음 그룹까지 1180px 이상 5px, 미만 3px. 최대 너비 378px·높이 42px·반응형 More 이동 단계는 그대로(버튼 좌우 여백 5px), `--hm-sw-viewer-tab-background` #0e3d2f(Gradient 아래 바탕), `--hm-sw-viewer-tab-gradient` `linear-gradient(180deg, #081813 0%, #09271e 8.6884%, #0b3227 25.428%, #0e3d2f 51.252%, #0b3227 80.027%, #0a2b21 89.759%, #081813 100%)`. 테두리 토큰은 이미지(Figma Gradient Stroke, 위→아래)이며 단색은 `linear-gradient(<색>, <색>)`로 지정합니다. 선택: `--hm-sw-viewer-tab-active-border` `linear-gradient(180deg, #a67d03 0%, #f2cc0f 16.4%, #fadd4f 37.2%, #f2cc0f 62%, #926e03 87.9%)`, `--hm-sw-viewer-tab-active-text` = `var(--hm-sw-viewer-accent)`(#ffc600). 비선택: `--hm-sw-viewer-tab-inactive-border` `linear-gradient(180deg, #189b6e 0%, #287a5e 100%)`, `--hm-sw-viewer-tab-inactive-text` = `var(--hm-sw-viewer-text)`(#fff). 글자는 두 상태 모두 세로 가운데. 긴 이름(2026-10-23): 열은 `minmax(0, 1fr)`로 같은 폭을 유지하며 상자(최대 378px)를 넘지 않고, 이름(`.hm-sw-viewer-header__tab-name`)이 먼저 `text-overflow: ellipsis`로 줄며 개수는 줄이지 않음. `--hm-sw-viewer-tab-name-basis` 3em(14px에서 42px, 기본 "Model" 폭): 공간 계산에서 이름은 이 폭까지만 셈(더 짧은 이름은 자기 폭) — 긴 이름도 일반 이름과 같은 탭 폭·같은 반응형 단계(다른 메뉴 아이콘화·More 이동이 같음)이고, 상자에 남는 공간이 있을 때만 그만큼 이름이 더 보임. 광택(비선택 상단): `--hm-sw-viewer-tab-highlight-color` #2d8b6b → `--hm-sw-viewer-tab-highlight-end` `rgba(11, 45, 34, 0)`, `--hm-sw-viewer-tab-highlight-opacity` 0.67, `--hm-sw-viewer-tab-highlight-height` 20px. 상호작용: `--hm-sw-viewer-tab-hover-border` = `var(--hm-sw-viewer-tab-inactive-border)`, `--hm-sw-viewer-tab-hover-text` = `var(--hm-sw-viewer-accent)`, `--hm-sw-viewer-tab-focus-color` = `var(--hm-sw-viewer-accent)`. `--hm-sw-viewer-tab-border-face`는 상태별 테두리를 고르는 내부 변수라 지정하지 않습니다 | | Model / Layout 탭(Figma 7652:40344, 2026-10-23) | 컨테이너: `--hm-sw-viewer-tabs-height` = `calc(var(--hm-sw-viewer-control-height) + 2px)` = 42px(옆 field 그룹 바깥 높이와 같음; 바깥 1px은 투명 가장자리(외곽선 제거 후에도 크기 유지용), 그 안 검정 여백 위·좌우 1px·아래 1.5px, 버튼은 나머지 37.5px를 채움), `--hm-sw-viewer-tabs-radius` 6px, `--hm-sw-viewer-tabs-background` `rgba(0, 0, 0, 0.8)`(면), `--hm-sw-viewer-tabs-glow-color` `transparent`(추가 Glow 색, 기본 없음 — 녹색 외곽선은 2026-10-23 사용자 요청으로 제거되어 외곽선 토큰 없음), `--hm-sw-viewer-tabs-glow` `0 0 4px var(--hm-sw-viewer-tabs-glow-color)`, `--hm-sw-viewer-tabs-shadow` `inset 0 2.857px 2.857px rgba(0, 0, 0, 0.25)`(버튼 위에 겹치는 안쪽 그림자). 버튼 공통: 높이는 컨테이너를 채움(별도 높이 토큰 없음), `--hm-sw-viewer-tab-radius` 5px(바깥 모서리만), `--hm-sw-viewer-tab-border-width` 1.428px, `--hm-sw-viewer-tab-gap` 14px(이름–개수; 버튼 좌우 안쪽 여백 5px와 함께 기존 기본 폭·측정 단계 유지). 객체 면의 첫 그룹이 Model / Layout 탭만 가진 경우에만 그 그룹 오른쪽에 `margin-right: 1px`(조건부 규칙, 다른 Header 그룹 간격은 객체 면 gap 그대로): 다음 그룹까지 1180px 이상 5px, 미만 3px. 최대 너비 378px·높이 42px·반응형 More 이동 단계는 그대로(버튼 좌우 여백 5px), `--hm-sw-viewer-tab-background` #0e3d2f(Gradient 아래 바탕), `--hm-sw-viewer-tab-gradient` `linear-gradient(180deg, #081813 0%, #09271e 8.6884%, #0b3227 25.428%, #0e3d2f 51.252%, #0b3227 80.027%, #0a2b21 89.759%, #081813 100%)`. 테두리 토큰은 이미지(Figma Gradient Stroke, 위→아래)이며 단색은 `linear-gradient(<색>, <색>)`로 지정합니다. 선택: `--hm-sw-viewer-tab-active-border` `linear-gradient(180deg, #a67d03 0%, #f2cc0f 16.4%, #fadd4f 37.2%, #f2cc0f 62%, #926e03 87.9%)`, `--hm-sw-viewer-tab-active-text` = `var(--hm-sw-viewer-accent)`(#ffc600). 비선택: `--hm-sw-viewer-tab-inactive-border` `linear-gradient(180deg, #189b6e 0%, #287a5e 100%)`, `--hm-sw-viewer-tab-inactive-text` = `var(--hm-sw-viewer-text)`(#fff). 글자는 두 상태 모두 세로 가운데. 긴 이름(2026-10-23): 열은 `minmax(0, 1fr)`로 같은 폭을 유지하며 상자(최대 378px)를 넘지 않고, 이름(`.hm-sw-viewer-header__tab-name`)이 먼저 `text-overflow: ellipsis`로 줄며 개수는 줄이지 않음. `--hm-sw-viewer-tab-name-basis` 3em(14px에서 42px, 기본 "Model" 폭): 공간 계산에서 이름은 이 폭까지만 셈(더 짧은 이름은 자기 폭) — 긴 이름도 일반 이름과 같은 탭 폭·같은 반응형 단계(다른 메뉴 아이콘화·More 이동이 같음)이고, 상자에 남는 공간이 있을 때만 그만큼 이름이 더 보임. 광택(비선택 상단): `--hm-sw-viewer-tab-highlight-color` #2d8b6b → `--hm-sw-viewer-tab-highlight-end` `rgba(11, 45, 34, 0)`, `--hm-sw-viewer-tab-highlight-opacity` 0.67, `--hm-sw-viewer-tab-highlight-height` 20px. 상호작용: `--hm-sw-viewer-tab-hover-border` = `var(--hm-sw-viewer-tab-inactive-border)`, `--hm-sw-viewer-tab-hover-text` = `var(--hm-sw-viewer-accent)`, `--hm-sw-viewer-tab-focus-color` = `var(--hm-sw-viewer-accent)`. `--hm-sw-viewer-tab-border-face`는 상태별 테두리를 고르는 내부 변수라 지정하지 않습니다 |
| Header 스위치·아이콘 | `--hm-sw-viewer-switch-off` #020a07(OFF 트랙), `--hm-sw-viewer-switch-thumb-off` #5f7a71(OFF 원형, 흐린 회녹색), `--hm-sw-viewer-switch-on` = `var(--hm-sw-viewer-hover)`(ON 트랙), `--hm-sw-viewer-switch-thumb-on` = `var(--hm-sw-viewer-muted)`(ON 원형, 민트; 노란색·테두리 없음, 2026-10-22), `--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)`, `--hm-sw-viewer-icon-outline-color` = `var(--hm-sw-header-text-outline-color)`, `--hm-sw-viewer-icon-outline-width` 1px | | Header 스위치·아이콘 | `--hm-sw-viewer-switch-off` #020a07(OFF 트랙), `--hm-sw-viewer-switch-thumb-off` #5f7a71(OFF 원형, 흐린 회녹색), `--hm-sw-viewer-switch-on` = `var(--hm-sw-viewer-hover)`(ON 트랙), `--hm-sw-viewer-switch-thumb-on` = `var(--hm-sw-viewer-muted)`(ON 원형, 민트; 노란색·테두리 없음, 2026-10-22), `--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)`, `--hm-sw-viewer-icon-outline-color` = `var(--hm-sw-header-text-outline-color)`, `--hm-sw-viewer-icon-outline-width` 1px |
| Dropdown·More | `--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 | | Dropdown·More | `--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 |
+3 -3
View File
@@ -31,8 +31,8 @@
| `type: "button"` | `onSelect` | | `type: "button"` | `onSelect` |
| `type: "toggle"` | `pressed`, `onPressedChange` (패널 열기/닫기 등) | | `type: "toggle"` | `pressed`, `onPressedChange` (패널 열기/닫기 등) |
| `type: "menu"` | `entries: SWViewerMenuEntry[]`, `menuWidth?`, `menuAlign?: "start" \| "end"` | | `type: "menu"` | `entries: SWViewerMenuEntry[]`, `menuWidth?`, `menuAlign?: "start" \| "end"` |
| `type: "tabs"` | `value`, `options: { value, label, count?, title?, disabled? }[]`, `onValueChange` (Model / Layout). 폭(2026-10-23): 두 버튼은 항상 같은 폭(가장 긴 이름 + 개수 이상이라 겹치지 않음). tabs만 있는 그룹은 Header에 남는 공간만큼 넓어져 field 면 최대 378px(`--hm-sw-viewer-tabs-max-width`), 공간이 적으면 그만큼만 넓어지고 기본 폭보다 부족하면 기존 측정 단계대로 More로 이동합니다. 모양(2026-10-23, Figma 7652:40344): tabs만 있는 그룹은 field 면 없이 옆 field 그룹과 같은 42px(`--hm-sw-viewer-control-height` + 2px) 어두운 컨테이너(검정 여백 위·좌우 1px·아래 1.5px, 안쪽 그림자; 녹색 외곽선은 2026-10-23 제거)가 면이 되고, 버튼(컨테이너를 채워 37.5px)은 같은 Gradient 면과 1.428px Gradient 테두리(선택 금색 #a67d03→#fadd4f→#926e03 + 글자 #ffc600, 비선택 #189b6e→#287a5e + 흰 글자 + 상단 초록 광택), 이름 14px 700·개수 14px 400, 간격 14px, 긴 이름은 버튼 안에서 이름부터 `…`로 줄고 개수는 그대로(두 버튼 같은 폭·최대 378px 유지; 공간 계산에서 이름은 `--hm-sw-viewer-tab-name-basis`(3em)까지만 세므로 긴 이름이 탭 폭을 늘리거나 다른 메뉴의 아이콘화·More 이동을 바꾸지 않음; 전체 이름은 접근 가능한 이름으로 남고 툴팁이 필요하면 option `title`)(버튼 좌우 여백 5px), 객체 면 첫 그룹이 이 탭만 가진 경우에만 그룹 오른쪽 여백 1px(조건부, 다른 Header 그룹에는 없음 — 다음 그룹까지 1180px 이상 5px, 미만 3px; 최대 378px·높이 42px·More 이동 단계 그대로), 글자 외곽선은 기존 그대로입니다. 색은 `--hm-sw-viewer-tabs-*`·`--hm-sw-viewer-tab-*` 토큰으로만 바꿉니다(목록·기본값·Override: [DESIGN_RULES.md](./DESIGN_RULES.md) §11) | | `type: "tabs"` | `value`, `options: { value, label, count?, title?, disabled? }[]`, `onValueChange` (Model / Layout). 폭(2026-10-23): 두 버튼은 항상 같은 폭(가장 긴 이름 + 개수 이상이라 겹치지 않음). tabs만 있는 그룹은 Header에 남는 공간만큼 넓어져 field 면 최대 378px(`--hm-sw-viewer-tabs-max-width`), 공간이 적으면 그만큼만 넓어집니다. 기본 폭보다 부족하면(2026-10-26) More로 옮기기 전에 최소 `--hm-sw-viewer-tabs-min-width`(48px)까지 좁아지며 이름부터, 다음으로 개수까지 `…`로 줄고(Header 560px 미만에서는 개수 숨김), 그 최소 폭으로도 들어가지 않을 때만 `collapseBelow` 단계대로 More로 이동합니다. 모양(2026-10-23, Figma 7652:40344): tabs만 있는 그룹은 field 면 없이 옆 field 그룹과 같은 42px(`--hm-sw-viewer-control-height` + 2px) 어두운 컨테이너(검정 여백 위·좌우 1px·아래 1.5px, 안쪽 그림자; 녹색 외곽선은 2026-10-23 제거)가 면이 되고, 버튼(컨테이너를 채워 37.5px)은 같은 Gradient 면과 1.428px Gradient 테두리(선택 금색 #a67d03→#fadd4f→#926e03 + 글자 #ffc600, 비선택 #189b6e→#287a5e + 흰 글자 + 상단 초록 광택), 이름 14px 700·개수 14px 400, 간격 14px, 긴 이름은 버튼 안에서 이름부터 `…`로 줄고 개수는 그대로(두 버튼 같은 폭·최대 378px 유지; 공간 계산에서 이름은 `--hm-sw-viewer-tab-name-basis`(3em)까지만 세므로 긴 이름이 탭 폭을 늘리거나 다른 메뉴의 아이콘화·More 이동을 바꾸지 않음; 전체 이름은 접근 가능한 이름으로 남고 툴팁이 필요하면 option `title`)(버튼 좌우 여백 5px), 객체 면 첫 그룹이 이 탭만 가진 경우에만 그룹 오른쪽 여백 1px(조건부, 다른 Header 그룹에는 없음 — 다음 그룹까지 1180px 이상 5px, 미만 3px; 최대 378px·높이 42px·More 이동 단계 그대로), 글자 외곽선은 기존 그대로입니다. 색은 `--hm-sw-viewer-tabs-*`·`--hm-sw-viewer-tab-*` 토큰으로만 바꿉니다(목록·기본값·Override: [DESIGN_RULES.md](./DESIGN_RULES.md) §11) |
| `type: "switch"` | `checked`, `onCheckedChange` (2.5D 등, 위 11px 캡션) | | `type: "switch"` | `checked`, `onCheckedChange` (2.5D 등, 위 11px 캡션), `shortLabel?`(2026-10-26: Header 560px 미만에서 `label` 대신 보이는 짧은 캡션, 예 `어두운 배경` → `배경`. `label`은 접근 가능한 이름으로 유지, 없으면 `label` 그대로) |
| `type: "custom"` | `render()`, `menuEntries?`(More에 넣을 항목) | | `type: "custom"` | `render()`, `menuEntries?`(More에 넣을 항목) |
| `onCommand(command)` | 모든 명령을 한 곳에서 받습니다: `{ id, itemId, value? }`. 항목 자체 Callback 다음에 호출 | | `onCommand(command)` | 모든 명령을 한 곳에서 받습니다: `{ id, itemId, value? }`. 항목 자체 Callback 다음에 호출 |
| `documentTitle`, `documentPlaceholder`, `documentTooltip`, `document?` | 파일명 면 | | `documentTitle`, `documentPlaceholder`, `documentTooltip`, `document?` | 파일명 면 |
@@ -43,7 +43,7 @@
| `trailing?: ReactNode` | 오른쪽 끝 자유 영역: `SWViewerStatus`, `SWViewerHeaderButton`, 창 최소화·닫기 버전이면 맨 끝에 `SWHeaderWindowControls`(§2.1). 이 세 부품은 `SWHeader` 창 영역처럼 전체 높이(48px)의 칸이 이어진 띠로 그려집니다: 같은 면, 칸 앞 1px 구분선(라이선스 버튼 앞뒤에는 없음), 위·아래 경계는 객체 면 테두리 하나라 모든 칸의 위·아래가 같음, 칸 사이 여백 없음, 객체 면 오른쪽 끝까지(2026-10-21). 메뉴 면은 띠 앞에서 `SWHeader` 객체 영역처럼 오른쪽 라운드(`--hm-sw-header-object-radius` 6px, 같은 면·테두리)로 끝나고 2px 간격 뒤에 띠가 옵니다. 상태 칸은 표시 전용, 아이콘 버튼(정보·라이선스) 칸은 테두리·선 없음 | | `trailing?: ReactNode` | 오른쪽 끝 자유 영역: `SWViewerStatus`, `SWViewerHeaderButton`, 창 최소화·닫기 버전이면 맨 끝에 `SWHeaderWindowControls`(§2.1). 이 세 부품은 `SWHeader` 창 영역처럼 전체 높이(48px)의 칸이 이어진 띠로 그려집니다: 같은 면, 칸 앞 1px 구분선(라이선스 버튼 앞뒤에는 없음), 위·아래 경계는 객체 면 테두리 하나라 모든 칸의 위·아래가 같음, 칸 사이 여백 없음, 객체 면 오른쪽 끝까지(2026-10-21). 메뉴 면은 띠 앞에서 `SWHeader` 객체 영역처럼 오른쪽 라운드(`--hm-sw-header-object-radius` 6px, 같은 면·테두리)로 끝나고 2px 간격 뒤에 띠가 옵니다. 상태 칸은 표시 전용, 아이콘 버튼(정보·라이선스) 칸은 테두리·선 없음 |
| `moreLabel?`, `toolbarLabel?`, `<header>` 속성, `ref` | | | `moreLabel?`, `toolbarLabel?`, `<header>` 속성, `ref` | |
- **반응형(2026-10-21, 실제 가용 폭 기준 3단계):** 고정 Breakpoint를 쓰지 않고 Header 행의 실제 남는 폭(로고·파일명·메뉴·Gap·Padding·More 버튼 포함)을 측정합니다. 파일명 면은 글자 수가 아니라 Header 폭으로 정한 기본 폭으로 계산하므로, 같은 폭에서는 짧은 파일명과 긴 파일명의 단계·파일 열기 버튼·파일명 면 너비가 같고 긴 이름은 `…`입니다(2026-10-21). ① 모두 들어가면 기본 형태(아이콘+텍스트), ② 부족하면 `icon`과 `iconOnlyBelow`가 있는 항목을 모두 아이콘만(라벨은 Tooltip·접근성 이름), ③ 그래도 부족하면 `collapseBelow` 항목을 하나씩 More(⋯, 베이지 260px Dropdown)로 옮깁니다(값이 큰 것부터, 같으면 오른쪽 먼저). 넓어지면 More → 아이콘 → 기본 순서로 복귀하고, 상단과 More에 같은 항목이 동시에 나오지 않습니다. `collapseBelow`·`iconOnlyBelow`의 숫자는 px Breakpoint가 아니며 순서만 정합니다(`iconOnlyBelow`는 지정 여부만, `collapseBelow`는 값이 큰 것부터; Props 형식 동일). 로고 옆 파일 열기 버튼(`onDocumentOpen`)은 ② 단계에서 아이콘만으로 줄고 More로 옮기지 않으며, 샘플은 `documents`. 레이어·설정·속성은 `collapseBelow` 없이 아이콘까지만. 1180px 미만 간격 2px, 560px 미만 상태 글자 숨김(CSS container)은 그대로입니다. 가로 스크롤은 만들지 않습니다. - **반응형(2026-10-21, 실제 가용 폭 기준 3단계):** 고정 Breakpoint를 쓰지 않고 Header 행의 실제 남는 폭(로고·파일명·메뉴·Gap·Padding·More 버튼 포함)을 측정합니다. 파일명 면은 글자 수가 아니라 Header 폭으로 정한 기본 폭으로 계산하므로, 같은 폭에서는 짧은 파일명과 긴 파일명의 단계·파일 열기 버튼·파일명 면 너비가 같고 긴 이름은 `…`입니다(2026-10-21). ① 모두 들어가면 기본 형태(아이콘+텍스트), ② 부족하면 `icon`과 `iconOnlyBelow`가 있는 항목을 모두 아이콘만(라벨은 Tooltip·접근성 이름), ③ 그래도 부족하면 `collapseBelow` 항목을 하나씩 More(⋯, 베이지 260px Dropdown)로 옮깁니다(값이 큰 것부터, 같으면 오른쪽 먼저). ③의 판단(2026-10-26)은 tabs를 최소 폭(`--hm-sw-viewer-tabs-min-width`)으로 세므로, tabs가 `…`로 좁아져서 들어가면 More로 옮기지 않습니다. 파일명 면 기본 폭은 `clamp(80px, min(45cqw - 256px, 100cqw - 780px), 360px)`(1280px 이상은 이전과 같음), Header 560px 미만에서는 56px입니다. 넓어지면 More → 아이콘 → 기본 순서로 복귀하고, 상단과 More에 같은 항목이 동시에 나오지 않습니다. `collapseBelow`·`iconOnlyBelow`의 숫자는 px Breakpoint가 아니며 순서만 정합니다(`iconOnlyBelow`는 지정 여부만, `collapseBelow`는 값이 큰 것부터; Props 형식 동일). 로고 옆 파일 열기 버튼(`onDocumentOpen`)은 ② 단계에서 아이콘만으로 줄고 More로 옮기지 않으며, 샘플은 `documents`. 레이어·설정·속성은 `collapseBelow` 없이 아이콘까지만. 1180px 미만 간격 2px, 560px 미만 상태 글자 숨김(CSS container)은 그대로이고, 560px 미만에서는 tabs 개수 숨김·switch `shortLabel` 캡션·switch 좌우 여백 1px도 적용됩니다(2026-10-26). 가로 스크롤은 만들지 않습니다.
- More는 그룹/항목 단위 구획으로 자동 구성됩니다: menu·tabs는 자기 구획(`moreLabel`), button·toggle·switch는 그룹 구획(`group.moreLabel ?? group.label`). toggle·switch는 체크 항목, tabs는 라디오 항목이 됩니다. - 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`(우클릭 메뉴 등)는 기존 색 그대로입니다. - **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·Active·Pressed·열림·선택 탭 = 글자·아이콘 #FFC600(면·선택 박스 없음, Hover 전후 크기·위치 동일 — 2026-10-21 Hover 면 #266651 제거), Disabled = 60% 불투명, Focus = 2px #FFC600 외곽선. - **상태 표현:** Hover·Active·Pressed·열림·선택 탭 = 글자·아이콘 #FFC600(면·선택 박스 없음, Hover 전후 크기·위치 동일 — 2026-10-21 Hover 면 #266651 제거), Disabled = 60% 불투명, Focus = 2px #FFC600 외곽선.
@@ -121,9 +121,11 @@ function responsiveSteps(groups: SWViewerHeaderGroup[], extraIcons: string[] = [
* width up to its usual width (so a short name leaves room to the menus), the object surface without stretching and * 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, * 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. * never painted; positioned layers (menus, logo menu) never count. Same method as SWHeader's measured More.
* `tabsAtMin` (2026-10-26): once every icon step is applied, the Model / Layout tabs count at their minimum width
* (`data-measuring="min"`), so they narrow with an ellipsis before anything moves into More.
*/ */
function measureRoom(root: HTMLElement) { function measureRoom(root: HTMLElement, tabsAtMin = false) {
root.setAttribute("data-measuring", "") root.setAttribute("data-measuring", tabsAtMin ? "min" : "")
const style = getComputedStyle(root), box = root.getBoundingClientRect() const style = getComputedStyle(root), box = root.getBoundingClientRect()
const end = box.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight) const end = box.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight)
let used = box.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft) let used = box.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft)
@@ -226,7 +228,11 @@ function SwitchField({ item, onCommand }: { item: Extract<SWViewerHeaderItem, {
const id = useId() const id = useId()
return ( return (
<div className="hm-sw-viewer-header__switch-field" title={item.title}> <div className="hm-sw-viewer-header__switch-field" title={item.title}>
<label htmlFor={id} className="hm-sw-viewer-header__switch-label">{item.label}</label> {/* shortLabel (2026-10-26): the narrow-header caption (sw-viewer.css); the full label stays the accessible name. */}
<label htmlFor={id} className="hm-sw-viewer-header__switch-label" data-short={item.shortLabel ? "" : undefined}>
<span className="hm-sw-viewer-header__switch-label-full">{item.label}</span>
{item.shortLabel && <span className="hm-sw-viewer-header__switch-label-short" aria-hidden="true">{item.shortLabel}</span>}
</label>
<SWViewerSwitch id={id} checked={item.checked} disabled={item.disabled} <SWViewerSwitch id={id} checked={item.checked} disabled={item.disabled}
onCheckedChange={next => { item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} /> onCheckedChange={next => { item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} />
</div> </div>
@@ -342,7 +348,7 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
if (level !== 0) return setLevel(0) if (level !== 0) return setLevel(0)
} }
if (level > steps.length) return setLevel(steps.length) if (level > steps.length) return setLevel(steps.length)
const room = measureRoom(root) const room = measureRoom(root, level >= steps.filter(step => step.kind === "icons").length)
const freed = freedRef.current const freed = freedRef.current
const last = level > 0 ? freed[level - 1] : undefined const last = level > 0 ? freed[level - 1] : undefined
if (last && last.room === undefined) last.room = room - last.before if (last && last.room === undefined) last.room = room - last.before
@@ -108,6 +108,11 @@ export type SWViewerHeaderItem =
| (SWViewerHeaderItemBase & { | (SWViewerHeaderItemBase & {
/** Caption above a switch, e.g. 2.5D. */ /** Caption above a switch, e.g. 2.5D. */
type: "switch" type: "switch"
/**
* Shorter caption shown instead of `label` in a narrow header (below 560px header width), e.g. 배경 for 어두운 배경.
* `label` stays the accessible name. Default: `label`.
*/
shortLabel?: string
checked: boolean checked: boolean
onCheckedChange?: (checked: boolean) => void onCheckedChange?: (checked: boolean) => void
}) })
+33 -13
View File
@@ -28,6 +28,9 @@
--hm-sw-viewer-button-height: 34px; --hm-sw-viewer-button-height: 34px;
/* Model / Layout field face: widest size (2026-10-23); narrower when the header has less room. */ /* Model / Layout field face: widest size (2026-10-23); narrower when the header has less room. */
--hm-sw-viewer-tabs-max-width: 378px; --hm-sw-viewer-tabs-max-width: 378px;
/* Narrowest Model / Layout field (2026-10-26): below its natural width the tabs narrow down to this (names and counts
ellipsized) before the measured steps move anything into More. */
--hm-sw-viewer-tabs-min-width: 48px;
/* Model / Layout colors and shape (Figma 7652:40344, 2026-10-23). Used only by .hm-sw-viewer-header__tabs / __tab: /* Model / Layout colors and shape (Figma 7652:40344, 2026-10-23). Used only by .hm-sw-viewer-header__tabs / __tab:
set any of them on the SW scope or the header to recolor the tabs alone. Container: */ set any of them on the SW scope or the header to recolor the tabs alone. Container: */
/* The outer height of the adjacent field group (40px control row + its 1px top and bottom border = 42px). */ /* The outer height of the adjacent field group (40px control row + its 1px top and bottom border = 42px). */
@@ -165,10 +168,11 @@ button.hm-sw-viewer-header__brand { text-align: left; }
align-items: center; align-items: center;
/* Very large shrink weight (2026-10-23): the file name gives way first, as before; the object (shrink 1) only once /* Very large shrink weight (2026-10-23): the file name gives way first, as before; the object (shrink 1) only once
the file name is at its minimum (see .hm-sw-viewer-header__object). the file name is at its minimum (see .hm-sw-viewer-header__object).
2026-10-26 (user request): narrower below 1280px (171px at 950px, was 25cqw = 237px) so a 950px header has no More; 2026-10-26 (user request): narrower below 1280px so 950px and 860px headers have no More (about 170px at 950px and
from 1280px the same as before (320px, 360px from 1370px). The measurement (data-measuring) uses the same value. */ 80px at 860px, was 25cqw = 237px / 215px; minimum 80px, was 140px); from 1280px the same as before (320px, 360px
flex: 0 1000000 clamp(140px, calc(45cqw - 256px), 360px); from 1370px). The measurement (data-measuring) uses the same value. */
width: clamp(220px, calc(45cqw - 256px), 360px); flex: 0 1000000 clamp(80px, min(45cqw - 256px, 100cqw - 780px), 360px);
width: clamp(220px, min(45cqw - 256px, 100cqw - 780px), 360px);
min-width: 56px; min-width: 56px;
height: calc(var(--hm-sw-viewer-height) - 2px); height: calc(var(--hm-sw-viewer-height) - 2px);
padding: 0 16px 0 24px; padding: 0 16px 0 24px;
@@ -314,7 +318,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
Every part at its natural width: the file name at its usual flex basis (2026-10-21 user request: the header width Every part at its natural width: the file name at its usual flex basis (2026-10-21 user request: the header width
decides it, never the length of the name, so a short and a long name give the same steps, Open label and widths), decides it, never the length of the name, so a short and a long name give the same steps, Open label and widths),
the object surface neither stretched nor shrunk, the trailing area without its auto margin. */ the object surface neither stretched nor shrunk, the trailing area without its auto margin. */
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__document { flex: 0 0 clamp(140px, calc(45cqw - 256px), 360px); } .hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__document { flex: 0 0 clamp(80px, min(45cqw - 256px, 100cqw - 780px), 360px); }
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__object { flex: 0 0 auto; } .hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__object { flex: 0 0 auto; }
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__actions { margin-left: 0; } .hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__actions { margin-left: 0; }
/* While the logo menu or the document list is open, the header takes the viewer dropdown layer (SWViewerMenu, 60) so /* While the logo menu or the document list is open, the header takes the viewer dropdown layer (SWViewerMenu, 60) so
@@ -403,8 +407,11 @@ button.hm-sw-viewer-header__brand { text-align: left; }
not-selected one has a green top gloss. Every color is a --hm-sw-viewer-tab(s)-* token (see the token block). not-selected one has a green top gloss. Every color is a --hm-sw-viewer-tab(s)-* token (see the token block).
Width (2026-10-23, kept): equal columns (1fr: each at least the widest name + count, so they never overlap). A group Width (2026-10-23, kept): equal columns (1fr: each at least the widest name + count, so they never overlap). A group
holding only the tabs starts at that natural width and takes the object's free room up to holding only the tabs starts at that natural width and takes the object's free room up to
--hm-sw-viewer-tabs-max-width (the container is the group face then); with less room it stays narrower, and below --hm-sw-viewer-tabs-max-width (the container is the group face then); with less room it stays narrower.
the natural width the measured steps move it into More as before (`data-measuring` uses the natural width). */ 2026-10-26 (user request): below the natural width the tabs narrow (names, then counts ellipsized) down to
--hm-sw-viewer-tabs-min-width instead of moving into More. The group's own width is that minimum and it grows into
the free room, so the file name keeps its width; the measurement uses the natural width until every icon step is
applied, then the minimum (`data-measuring="min"`, SWViewerHeader). */
.hm-sw-viewer-header__tabs { .hm-sw-viewer-header__tabs {
position: relative; position: relative;
display: grid; display: grid;
@@ -431,7 +438,9 @@ button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-header__tabs::after { content: ""; position: absolute; z-index: 1; inset: 0; border-radius: var(--hm-sw-viewer-tabs-radius); box-shadow: var(--hm-sw-viewer-tabs-shadow); pointer-events: none; } .hm-sw-viewer-header__tabs::after { content: ""; position: absolute; z-index: 1; inset: 0; border-radius: var(--hm-sw-viewer-tabs-radius); box-shadow: var(--hm-sw-viewer-tabs-shadow); pointer-events: none; }
/* A group holding only the tabs: the container is its face (no field face around it, as in Figma: the tabs sit on /* A group holding only the tabs: the container is its face (no field face around it, as in Figma: the tabs sit on
the header object surface). */ the header object surface). */
.hm-sw-viewer-header__group:has(> .hm-sw-viewer-header__tabs:only-child) { flex: 1 0 auto; box-sizing: border-box; max-width: var(--hm-sw-viewer-tabs-max-width); min-height: 0; padding: 0; border: 0; background: none; box-shadow: none; } .hm-sw-viewer-header__group:has(> .hm-sw-viewer-header__tabs:only-child) { flex: 1 0 auto; box-sizing: border-box; width: var(--hm-sw-viewer-tabs-min-width); max-width: var(--hm-sw-viewer-tabs-max-width); min-height: 0; padding: 0; border: 0; background: none; box-shadow: none; }
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__group:has(> .hm-sw-viewer-header__tabs:only-child) { flex: 0 0 auto; width: max-content; }
.hm-sw-viewer-header[data-measuring="min"] .hm-sw-viewer-header__group:has(> .hm-sw-viewer-header__tabs:only-child) { width: var(--hm-sw-viewer-tabs-min-width); }
.hm-sw-viewer-header__group > .hm-sw-viewer-header__tabs:only-child { flex: 1 1 auto; } .hm-sw-viewer-header__group > .hm-sw-viewer-header__tabs:only-child { flex: 1 1 auto; }
/* 2026-10-23 user value: the tabs-only group that starts the object surface gets 1px more on its right (the buttons /* 2026-10-23 user value: the tabs-only group that starts the object surface gets 1px more on its right (the buttons
give it back in their side padding). Other groups keep the object gap. */ give it back in their side padding). Other groups keep the object gap. */
@@ -475,18 +484,20 @@ button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-header .hm-sw-viewer-header__tab[data-state="active"] { --hm-sw-viewer-tab-border-face: var(--hm-sw-viewer-tab-active-border); color: var(--hm-sw-viewer-tab-active-text); } .hm-sw-viewer-header .hm-sw-viewer-header__tab[data-state="active"] { --hm-sw-viewer-tab-border-face: var(--hm-sw-viewer-tab-active-border); color: var(--hm-sw-viewer-tab-active-text); }
.hm-sw-viewer-header__count { font-size: var(--hm-sw-viewer-label-size); font-weight: 400; font-variant-numeric: tabular-nums; } .hm-sw-viewer-header__count { font-size: var(--hm-sw-viewer-label-size); font-weight: 400; font-variant-numeric: tabular-nums; }
/* In a tab the count has the name's size (Figma: 14px Regular) and color. */ /* In a tab the count has the name's size (Figma: 14px Regular) and color. */
.hm-sw-viewer-header__tab .hm-sw-viewer-header__count { flex: 0 0 auto; font-size: inherit; } .hm-sw-viewer-header__tab .hm-sw-viewer-header__count { flex: 0 1 auto; min-width: 0; overflow: clip; overflow-clip-margin: var(--hm-sw-header-text-outline-room); text-overflow: ellipsis; white-space: nowrap; font-size: inherit; }
/* Long names (2026-10-23 user decision): the name gives way first with an ellipsis, the count keeps its width; the /* Long names (2026-10-23 user decision): the name gives way first with an ellipsis, the count keeps its width; the
outline may paint 2px outside the clipped name (as the file name). The name counts at most outline may paint 2px outside the clipped name (as the file name). The name counts at most
--hm-sw-viewer-tab-name-basis in the tabs' natural width (width + max-width: max-content: shorter names keep their own --hm-sw-viewer-tab-name-basis in the tabs' natural width (width + max-width: max-content: shorter names keep their own
width), so a long name changes neither the tabs' width nor the measured steps of the other items; it grows (flex-grow, width), so a long name changes neither the tabs' width nor the measured steps of the other items; it grows (flex-grow,
up to its text) only into room the box has anyway. */ up to its text) only into room the box has anyway.
.hm-sw-viewer-header .hm-sw-viewer-header__tab { min-width: 0; } 2026-10-26 (narrow tabs): the name gives way first (large shrink weight) down to its first letter + ellipsis, then the
count ellipsizes too; whatever is still too wide is clipped at the button edge. */
.hm-sw-viewer-header .hm-sw-viewer-header__tab { min-width: 0; overflow: clip; }
.hm-sw-viewer-header__tab-name { .hm-sw-viewer-header__tab-name {
flex: 1 1 auto; flex: 1 1000 auto;
width: var(--hm-sw-viewer-tab-name-basis); width: var(--hm-sw-viewer-tab-name-basis);
max-width: max-content; max-width: max-content;
min-width: 0; min-width: 1.6em;
overflow: clip; overflow: clip;
overflow-clip-margin: var(--hm-sw-header-text-outline-room); overflow-clip-margin: var(--hm-sw-header-text-outline-room);
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -503,6 +514,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
min-height: var(--hm-sw-viewer-control-height); min-height: var(--hm-sw-viewer-control-height);
padding-inline: 8px; padding-inline: 8px;
} }
.hm-sw-viewer-header__switch-label-short { display: none; }
.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__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__brand, .hm-sw-viewer-switch):focus-visible { .hm-sw-viewer-header :is(.hm-sw-viewer-header__button, .hm-sw-viewer-header__brand, .hm-sw-viewer-switch):focus-visible {
@@ -668,6 +680,14 @@ button.hm-sw-viewer-header__brand { text-align: left; }
@container hm-sw-viewer (max-width: 559.98px) { @container hm-sw-viewer (max-width: 559.98px) {
.hm-sw-viewer-header__document { padding-right: 8px; } .hm-sw-viewer-header__document { padding-right: 8px; }
.hm-sw-viewer-status__label { display: none; } .hm-sw-viewer-status__label { display: none; }
/* 2026-10-26 (user request: every menu stays in the header): the file name goes down to 56px and a switch with a
`shortLabel` shows it as its caption (어두운 배경 → 배경); the full label stays its accessible name. */
.hm-sw-viewer-header__document, .hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__document { flex-basis: 56px; }
.hm-sw-viewer-header__switch-label[data-short] > .hm-sw-viewer-header__switch-label-full { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.hm-sw-viewer-header__switch-label[data-short] > .hm-sw-viewer-header__switch-label-short { display: inline; }
.hm-sw-viewer-header__switch-field { padding-inline: 1px; }
/* The Model / Layout counts are hidden (2026-10-26 user request); the names stay, ellipsized. */
.hm-sw-viewer-header__tab .hm-sw-viewer-header__count { display: none; }
} }
/* ---------- Switch (header / panel faces) ---------- */ /* ---------- Switch (header / panel faces) ---------- */
+26 -11
View File
@@ -309,11 +309,13 @@ if (comparison.reference) {
const tabs = element.querySelector('.hm-sw-viewer-header__tabs') const tabs = element.querySelector('.hm-sw-viewer-header__tabs')
const field = tabs?.parentElement const field = tabs?.parentElement
const tabWidths = tabs ? [...tabs.querySelectorAll('.hm-sw-viewer-header__tab')].map(tab => rect(tab).width) : [] const tabWidths = tabs ? [...tabs.querySelectorAll('.hm-sw-viewer-header__tab')].map(tab => rect(tab).width) : []
// Name and count of a button never overlap (and the button never clips them). // Name and count of a button never overlap and stay inside it (2026-10-26: narrow tabs ellipsize them, so their
// boxes are compared, not the full text).
const tabsFit = tabs ? [...tabs.querySelectorAll('.hm-sw-viewer-header__tab')].every(tab => { const tabsFit = tabs ? [...tabs.querySelectorAll('.hm-sw-viewer-header__tab')].every(tab => {
const count = tab.querySelector('.hm-sw-viewer-header__count'), range = document.createRange() const name = tab.querySelector('.hm-sw-viewer-header__tab-name'), count = tab.querySelector('.hm-sw-viewer-header__count'), box = rect(tab)
range.selectNodeContents(tab.firstChild) const shown = [name, count].filter(node => node && rect(node).width > 0)
return tab.scrollWidth <= tab.clientWidth && (!count || range.getBoundingClientRect().right <= rect(count).left) return shown.every(node => rect(node).left >= box.left - 0.5 && rect(node).right <= box.right + 0.5)
&& (shown.length < 2 || rect(name).right <= rect(count).left + 0.5)
}) : true }) : true
return { return {
width: Math.round(rect(element).width), width: Math.round(rect(element).width),
@@ -325,7 +327,8 @@ if (comparison.reference) {
field: field ? Math.round(rect(field).width * 10) / 10 : null, field: field ? Math.round(rect(field).width * 10) / 10 : null,
tabsEqual: tabWidths.every(width => Math.abs(width - tabWidths[0]) <= 0.5), tabsEqual: tabWidths.every(width => Math.abs(width - tabWidths[0]) <= 0.5),
tabsFit, tabsFit,
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')), // A switch caption with a shortLabel holds both texts; its full label is the one named here.
labels: [...element.querySelectorAll('.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__switch-label')].map(item => (item.querySelector('.hm-sw-viewer-header__switch-label-full') ?? item).textContent.trim() || item.getAttribute('aria-label')),
// Buttons shown as icons only by the measured steps (name = aria-label / title) and labels drawn as text. // Buttons shown as icons only by the measured steps (name = aria-label / title) and labels drawn as text.
iconOnly: [...element.querySelectorAll('.hm-sw-viewer-header__button[data-icon-only=""]')].map(item => item.getAttribute('aria-label') || item.getAttribute('title') || item.textContent.trim()).sort(), iconOnly: [...element.querySelectorAll('.hm-sw-viewer-header__button[data-icon-only=""]')].map(item => item.getAttribute('aria-label') || item.getAttribute('title') || item.textContent.trim()).sort(),
shownLabels: [...element.querySelectorAll('.hm-sw-viewer-header__label')].filter(item => rect(item).width > 0).map(item => item.textContent.trim()), shownLabels: [...element.querySelectorAll('.hm-sw-viewer-header__label')].filter(item => rect(item).width > 0).map(item => item.textContent.trim()),
@@ -352,10 +355,18 @@ if (comparison.reference) {
check('1920 / 1440 / 1280: every item in the header, no More', [1920, 1440, 1280].every(width => !at(width).more && at(width).field && ['전체 보기', '2.5D', '어두운 배경', '레이어'].every(name => at(width).labels.includes(name)))) check('1920 / 1440 / 1280: every item in the header, no More', [1920, 1440, 1280].every(width => !at(width).more && at(width).field && ['전체 보기', '2.5D', '어두운 배경', '레이어'].every(name => at(width).labels.includes(name))))
// 950 sits at the edge of the measured steps (icons only or More, depending on the font rendering); both fit. // 950 sits at the edge of the measured steps (icons only or More, depending on the font rendering); both fit.
check('950: icons only or More', at(950).more || !(await row(950).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count())) check('950: icons only or More', at(950).more || !(await row(950).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
check('860 / 760 / 520: More shown', [860, 760, 520].every(width => at(width).more)) // 2026-10-26 (user request): the file name gives way further (80px, below 560px 56px) and the Model / Layout tabs
check('860: icons only + More', at(860).more && !(await row(860).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count())) // narrow with an ellipsis, so 950 / 860 / 760 / 520 keep every item in the header (no More).
check('520: icons only + More; panel toggles kept as icons (label → aria-label)', at(520).more && ['레이어', '설정', '속성'].every(name => at(520).labels.includes(name)) check('950 / 860 / 760 / 520: every item in the header, icons only, no More', [950, 860, 760, 520].every(width => !at(width).more && at(width).field && ['Model', 'Layout', '전체 보기', '레이어'].every(name => at(width).labels.some(label => label.startsWith(name))))
&& !(await row(520).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count())) && !(await row(950).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()) && !(await row(520).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()), JSON.stringify([950, 860, 760, 520].map(width => [width, at(width).more, at(width).field])))
check('760: Model / Layout narrower than at 860', at(760).field < at(860).field, JSON.stringify([at(860).field, at(760).field]))
// Below 560px: counts hidden, 어두운 배경 shows its shortLabel 배경 (the full label stays the accessible name).
const narrow = await row(520).evaluate(element => ({
counts: [...element.querySelectorAll('.hm-sw-viewer-header__tab .hm-sw-viewer-header__count')].filter(node => node.getBoundingClientRect().width > 0).length,
captions: [...element.querySelectorAll('.hm-sw-viewer-header__switch-label')].map(label => [...label.children].filter(node => node.getBoundingClientRect().width > 1).map(node => node.textContent).join('')),
darkName: element.querySelector('.hm-sw-viewer-header__switch-label[data-short]')?.control?.labels?.[0]?.textContent,
}))
check('520: counts hidden, switch captions 2.5D / 배경', narrow.counts === 0 && JSON.stringify(narrow.captions) === JSON.stringify(['2.5D', '배경']), narrow)
check('no 파일 열기 / 샘플 header buttons at any width', report.every(item => !item.labels.includes('파일 열기') && !item.labels.includes('샘플'))) check('no 파일 열기 / 샘플 header buttons at any width', report.every(item => !item.labels.includes('파일 열기') && !item.labels.includes('샘플')))
// 2026-10-23: a header first drawn wide and then narrowed in place (window resize) takes the same measured steps // 2026-10-23: a header first drawn wide and then narrowed in place (window resize) takes the same measured steps
// (icons, then More) without overflow, and gets every item back when widened again. // (icons, then More) without overflow, and gets every item back when widened again.
@@ -368,11 +379,15 @@ if (comparison.reference) {
}, [width, max]) }, [width, max])
const shrink = [] const shrink = []
for (const width of [1920, 1440]) { for (const width of [1920, 1440]) {
for (const max of [1100, 1000, 900, 800]) shrink.push({ width, max, ...await resize(width, max) }) for (const max of [1100, 1000, 900, 800, 520, 440]) shrink.push({ width, max, ...await resize(width, max) })
shrink.push({ width, max: 'restored', ...await resize(width, null) }) shrink.push({ width, max: 'restored', ...await resize(width, null) })
} }
check('in-place shrink: no overflow, More once the room is short', shrink.every(item => item.overflow <= 0) && shrink.filter(item => item.max === 800).every(item => item.more), JSON.stringify(shrink.map(item => [item.width, item.max, item.overflow, item.more]))) // 2026-10-26: More only once even the narrowest tabs do not fit (below about 520px).
check('in-place shrink: no overflow, no More down to 520, More at 440', shrink.every(item => item.overflow <= 0) && shrink.filter(item => typeof item.max === 'number' && item.max >= 520).every(item => !item.more) && shrink.filter(item => item.max === 440).every(item => item.more), JSON.stringify(shrink.map(item => [item.width, item.max, item.overflow, item.more])))
check('in-place grow back: every label back, no More', shrink.filter(item => item.max === 'restored').every(item => !item.more && item.labels === at(item.width).labels.filter(label => ['전체 보기', '레이어', '설정', '속성'].includes(label)).length), JSON.stringify(shrink.filter(item => item.max === 'restored'))) check('in-place grow back: every label back, no More', shrink.filter(item => item.max === 'restored').every(item => !item.more && item.labels === at(item.width).labels.filter(label => ['전체 보기', '레이어', '설정', '속성'].includes(label)).length), JSON.stringify(shrink.filter(item => item.max === 'restored')))
// The More menu: the 520 row narrowed in place to 400px, where Model / Layout move into More too (2026-10-26).
const narrowed = await resize(520, 400)
check('520 row narrowed to 400: More shown, no overflow', narrowed.more && narrowed.overflow <= 0, JSON.stringify(narrowed))
const more = row(520).getByRole('button', { name: '추가 도구' }) const more = row(520).getByRole('button', { name: '추가 도구' })
await more.click() await more.click()
const moreMenu = page.getByRole('menu', { name: '추가 도구' }) const moreMenu = page.getByRole('menu', { name: '추가 도구' })
+4 -1
View File
@@ -70,15 +70,18 @@ export function useViewerDemo({ initialDocument = null as string | null, initial
const groups = useMemo<SWViewerHeaderGroup[]>(() => [ const groups = useMemo<SWViewerHeaderGroup[]>(() => [
{ {
id: "space", label: "도면 공간", surface: "field", hidden: !loaded, items: [ id: "space", label: "도면 공간", surface: "field", hidden: !loaded, items: [
// 2026-10-26 (user request): the tabs first narrow with an ellipsis (down to --hm-sw-viewer-tabs-min-width); only
// a header too narrow even then (below about 520px) moves the collapseBelow items into More.
{ type: "tabs", id: "space", label: "모델과 배치 선택", moreLabel: "도면 공간", collapseBelow: 1180, value: space, options: SPACES, onValueChange: setSpace }, { type: "tabs", id: "space", label: "모델과 배치 선택", moreLabel: "도면 공간", collapseBelow: 1180, value: space, options: SPACES, onValueChange: setSpace },
], ],
}, },
{ {
id: "view", label: "화면 보기", surface: "field", items: [ id: "view", label: "화면 보기", surface: "field", items: [
// 2026-10-21: shown while the header has room, icon only next, then into More (measured, see SWViewerHeader). // 2026-10-21: shown while the header has room, icon only next, then into More (measured, see SWViewerHeader).
// 2026-10-26: into More only below about 520px (after the tabs narrowed); 어두운 배경 shows 배경 in a narrow header.
{ type: "button", id: "fit", label: "전체 보기", icon: <Scan />, title: "전체 도면 맞춤 (F)", iconOnlyBelow: 560, collapseBelow: 1180, disabled: !loaded }, { type: "button", id: "fit", label: "전체 보기", icon: <Scan />, title: "전체 도면 맞춤 (F)", iconOnlyBelow: 560, collapseBelow: 1180, disabled: !loaded },
{ type: "switch", id: "2.5d", label: "2.5D", checked: is25D, collapseBelow: 1180, onCheckedChange: set25D }, { 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 }, { type: "switch", id: "dark", label: "어두운 배경", shortLabel: "배경", checked: dark, collapseBelow: 1180, onCheckedChange: setDark },
], ],
}, },
{ {