From cabd2f67de363613766143152258b7c1dbba539d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=88=98=ED=98=84?= Date: Mon, 26 Oct 2026 20:00:50 +0900 Subject: [PATCH] feat(sw): viewer header keeps every menu down to 520px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/sw/ai/COMPOSITION_PATTERNS.md | 2 +- packages/sw/ai/DESIGN_RULES.md | 2 +- packages/sw/ai/VIEWER.md | 6 +-- .../components/sw-viewer/SWViewerHeader.tsx | 14 ++++-- packages/sw/src/components/sw-viewer/types.ts | 5 ++ packages/sw/src/theme/sw-viewer.css | 46 +++++++++++++------ packages/sw/tests/viewer.browser.mjs | 37 ++++++++++----- src/sw/sw-viewer-fixture.tsx | 5 +- 8 files changed, 83 insertions(+), 34 deletions(-) diff --git a/packages/sw/ai/COMPOSITION_PATTERNS.md b/packages/sw/ai/COMPOSITION_PATTERNS.md index ebe1ef7..ef04bfa 100644 --- a/packages/sw/ai/COMPOSITION_PATTERNS.md +++ b/packages/sw/ai/COMPOSITION_PATTERNS.md @@ -175,7 +175,7 @@ const groups: SWViewerHeaderGroup[] = [ | 도구 창 | 비모달 `SWViewerPanel`(드래그·크기 조절·접기 기본). 여러 개면 `inset`·`defaultPosition`으로 겹치지 않게 | | 확인·정보 | `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) | -| 반응형 | 고정 너비가 아니라 Header의 실제 가용 폭으로 3단계: 기본(아이콘+텍스트) → `iconOnlyBelow` 항목 모두 아이콘만(툴팁 유지) → `collapseBelow` 항목을 하나씩 More로(값이 큰 것, 같으면 오른쪽 먼저). 넓어지면 역순 복귀. 패널 토글은 `collapseBelow` 없이 아이콘까지만 | +| 반응형 | 고정 너비가 아니라 Header의 실제 가용 폭으로 3단계: 기본(아이콘+텍스트) → `iconOnlyBelow` 항목 모두 아이콘만(툴팁 유지) → `collapseBelow` 항목을 하나씩 More로(값이 큰 것, 같으면 오른쪽 먼저). 넓어지면 역순 복귀. 패널 토글은 `collapseBelow` 없이 아이콘까지만. tabs는 More 전에 최소 48px까지 `…`로 좁아지고, 긴 switch 캡션은 `shortLabel`로 560px 미만에서 짧게(2026-10-26) | | 우클릭 메뉴 | `SWViewerMenu` + `anchorPoint` | - 상태(파일명·패널 열림·탭 값)는 프로그램이 가지고, 명령은 각 Callback 또는 `onCommand` 한 곳에서 받는다. 도면 영역 높이·렌더러 크기는 앱 레이아웃 담당(VIEWER.md §7). diff --git a/packages/sw/ai/DESIGN_RULES.md b/packages/sw/ai/DESIGN_RULES.md index 7de67cb..9b6abc4 100644 --- a/packages/sw/ai/DESIGN_RULES.md +++ b/packages/sw/ai/DESIGN_RULES.md @@ -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 | | 로고 면 | 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`는 상태별 테두리를 고르는 내부 변수라 지정하지 않습니다 | | 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 | diff --git a/packages/sw/ai/VIEWER.md b/packages/sw/ai/VIEWER.md index 2fa470a..75dce0c 100644 --- a/packages/sw/ai/VIEWER.md +++ b/packages/sw/ai/VIEWER.md @@ -31,8 +31,8 @@ | `type: "button"` | `onSelect` | | `type: "toggle"` | `pressed`, `onPressedChange` (패널 열기/닫기 등) | | `type: "menu"` | `entries: SWViewerMenuEntry[]`, `menuWidth?`, `menuAlign?: "start" \| "end"` | -| `type: "tabs"` | `value`, `options: { value, label, count?, title?, disabled? }[]`, `onValueChange` (Model / Layout). 폭(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: "switch"` | `checked`, `onCheckedChange` (2.5D 등, 위 11px 캡션) | +| `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 캡션), `shortLabel?`(2026-10-26: Header 560px 미만에서 `label` 대신 보이는 짧은 캡션, 예 `어두운 배경` → `배경`. `label`은 접근 가능한 이름으로 유지, 없으면 `label` 그대로) | | `type: "custom"` | `render()`, `menuEntries?`(More에 넣을 항목) | | `onCommand(command)` | 모든 명령을 한 곳에서 받습니다: `{ id, itemId, value? }`. 항목 자체 Callback 다음에 호출 | | `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 간격 뒤에 띠가 옵니다. 상태 칸은 표시 전용, 아이콘 버튼(정보·라이선스) 칸은 테두리·선 없음 | | `moreLabel?`, `toolbarLabel?`, `
` 속성, `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는 라디오 항목이 됩니다. - **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 외곽선. diff --git a/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx b/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx index f57a10c..caa112f 100644 --- a/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx +++ b/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx @@ -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 * 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. + * `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) { - root.setAttribute("data-measuring", "") +function measureRoom(root: HTMLElement, tabsAtMin = false) { + root.setAttribute("data-measuring", tabsAtMin ? "min" : "") 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) @@ -226,7 +228,11 @@ function SwitchField({ item, onCommand }: { item: Extract - + {/* shortLabel (2026-10-26): the narrow-header caption (sw-viewer.css); the full label stays the accessible name. */} + { item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} /> @@ -342,7 +348,7 @@ export const SWViewerHeader = forwardRef(funct if (level !== 0) return setLevel(0) } 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 last = level > 0 ? freed[level - 1] : undefined if (last && last.room === undefined) last.room = room - last.before diff --git a/packages/sw/src/components/sw-viewer/types.ts b/packages/sw/src/components/sw-viewer/types.ts index f33aa25..a4ef307 100644 --- a/packages/sw/src/components/sw-viewer/types.ts +++ b/packages/sw/src/components/sw-viewer/types.ts @@ -108,6 +108,11 @@ export type SWViewerHeaderItem = | (SWViewerHeaderItemBase & { /** Caption above a switch, e.g. 2.5D. */ 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 onCheckedChange?: (checked: boolean) => void }) diff --git a/packages/sw/src/theme/sw-viewer.css b/packages/sw/src/theme/sw-viewer.css index 27d9e14..07f5e33 100644 --- a/packages/sw/src/theme/sw-viewer.css +++ b/packages/sw/src/theme/sw-viewer.css @@ -28,6 +28,9 @@ --hm-sw-viewer-button-height: 34px; /* Model / Layout field face: widest size (2026-10-23); narrower when the header has less room. */ --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: 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). */ @@ -165,10 +168,11 @@ button.hm-sw-viewer-header__brand { text-align: left; } align-items: center; /* 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). - 2026-10-26 (user request): narrower below 1280px (171px at 950px, was 25cqw = 237px) so a 950px header has no More; - from 1280px the same as before (320px, 360px from 1370px). The measurement (data-measuring) uses the same value. */ - flex: 0 1000000 clamp(140px, calc(45cqw - 256px), 360px); - width: clamp(220px, calc(45cqw - 256px), 360px); + 2026-10-26 (user request): narrower below 1280px so 950px and 860px headers have no More (about 170px at 950px and + 80px at 860px, was 25cqw = 237px / 215px; minimum 80px, was 140px); from 1280px the same as before (320px, 360px + from 1370px). The measurement (data-measuring) uses the same value. */ + flex: 0 1000000 clamp(80px, min(45cqw - 256px, 100cqw - 780px), 360px); + width: clamp(220px, min(45cqw - 256px, 100cqw - 780px), 360px); min-width: 56px; height: calc(var(--hm-sw-viewer-height) - 2px); 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 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. */ -.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__actions { margin-left: 0; } /* 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). 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 - --hm-sw-viewer-tabs-max-width (the container is the group face then); with less room it stays narrower, and below - the natural width the measured steps move it into More as before (`data-measuring` uses the natural width). */ + --hm-sw-viewer-tabs-max-width (the container is the group face then); with less room it stays narrower. + 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 { position: relative; 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; } /* 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). */ -.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; } /* 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. */ @@ -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__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. */ -.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 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 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. */ -.hm-sw-viewer-header .hm-sw-viewer-header__tab { min-width: 0; } + up to its text) only into room the box has anyway. + 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 { - flex: 1 1 auto; + flex: 1 1000 auto; width: var(--hm-sw-viewer-tab-name-basis); max-width: max-content; - min-width: 0; + min-width: 1.6em; overflow: clip; overflow-clip-margin: var(--hm-sw-header-text-outline-room); text-overflow: ellipsis; @@ -503,6 +514,7 @@ button.hm-sw-viewer-header__brand { text-align: left; } min-height: var(--hm-sw-viewer-control-height); 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 :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) { .hm-sw-viewer-header__document { padding-right: 8px; } .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) ---------- */ diff --git a/packages/sw/tests/viewer.browser.mjs b/packages/sw/tests/viewer.browser.mjs index 8ca0d2e..84939c8 100644 --- a/packages/sw/tests/viewer.browser.mjs +++ b/packages/sw/tests/viewer.browser.mjs @@ -309,11 +309,13 @@ if (comparison.reference) { const tabs = element.querySelector('.hm-sw-viewer-header__tabs') const field = tabs?.parentElement 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 count = tab.querySelector('.hm-sw-viewer-header__count'), range = document.createRange() - range.selectNodeContents(tab.firstChild) - return tab.scrollWidth <= tab.clientWidth && (!count || range.getBoundingClientRect().right <= rect(count).left) + const name = tab.querySelector('.hm-sw-viewer-header__tab-name'), count = tab.querySelector('.hm-sw-viewer-header__count'), box = rect(tab) + const shown = [name, count].filter(node => node && rect(node).width > 0) + 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 return { width: Math.round(rect(element).width), @@ -325,7 +327,8 @@ if (comparison.reference) { field: field ? Math.round(rect(field).width * 10) / 10 : null, tabsEqual: tabWidths.every(width => Math.abs(width - tabWidths[0]) <= 0.5), 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. 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()), @@ -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)))) // 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('860 / 760 / 520: More shown', [860, 760, 520].every(width => at(width).more)) - check('860: icons only + More', at(860).more && !(await row(860).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count())) - check('520: icons only + More; panel toggles kept as icons (label → aria-label)', at(520).more && ['레이어', '설정', '속성'].every(name => at(520).labels.includes(name)) - && !(await row(520).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count())) + // 2026-10-26 (user request): the file name gives way further (80px, below 560px 56px) and the Model / Layout tabs + // narrow with an ellipsis, so 950 / 860 / 760 / 520 keep every item in the header (no More). + 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(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('샘플'))) // 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. @@ -368,11 +379,15 @@ if (comparison.reference) { }, [width, max]) const shrink = [] 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) }) } - 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'))) + // 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: '추가 도구' }) await more.click() const moreMenu = page.getByRole('menu', { name: '추가 도구' }) diff --git a/src/sw/sw-viewer-fixture.tsx b/src/sw/sw-viewer-fixture.tsx index 6338f8a..6a9e337 100644 --- a/src/sw/sw-viewer-fixture.tsx +++ b/src/sw/sw-viewer-fixture.tsx @@ -70,15 +70,18 @@ export function useViewerDemo({ initialDocument = null as string | null, initial const groups = useMemo(() => [ { 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 }, ], }, { 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-26: into More only below about 520px (after the tabs narrowed); 어두운 배경 shows 배경 in a narrow header. { type: "button", id: "fit", label: "전체 보기", icon: , 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: "dark", label: "어두운 배경", checked: dark, collapseBelow: 1180, onCheckedChange: setDark }, + { type: "switch", id: "dark", label: "어두운 배경", shortLabel: "배경", checked: dark, collapseBelow: 1180, onCheckedChange: setDark }, ], }, {