diff --git a/packages/sw/ai/DESIGN_RULES.md b/packages/sw/ai/DESIGN_RULES.md index b88969c..2f41f5a 100644 --- a/packages/sw/ai/DESIGN_RULES.md +++ b/packages/sw/ai/DESIGN_RULES.md @@ -144,18 +144,20 @@ | 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 | +| Model / Layout 탭(Figma 7652:40344, 2026-10-23) | 컨테이너: `--hm-sw-viewer-tabs-height` = `calc(var(--hm-sw-viewer-control-height) + 2px)` = 42px(옆 field 그룹 바깥 높이와 같음; 바깥 1px은 blur 외곽선 자리, 그 안 검정 여백 위·좌우 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-outline-color` #349f7f(42px 상자 바깥 가장자리 1px 선, `::before`가 상자와 같은 영역, blur는 `clip-path: inset(-1px …)`로 바깥 1px까지), `--hm-sw-viewer-tabs-outline-blur` 1px(그 선의 blur, Figma Layer blur 2), `--hm-sw-viewer-tabs-glow-color` `transparent`(추가 Glow 색, 기본 없음 — Figma는 blur 외곽선이 Glow), `--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). 글자는 두 상태 모두 세로 가운데. 광택(비선택 상단): `--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 | | 패널 콘텐츠 | `--hm-sw-viewer-panel-background` = `var(--hm-sw-property-surface)`, `--hm-sw-viewer-panel-field-background` = `var(--hm-sw-property-content)`, `--hm-sw-viewer-panel-inner-border` = `var(--hm-sw-property-inner-border)`, `--hm-sw-viewer-panel-text` = `var(--hm-sw-property-label)`, `--hm-sw-viewer-panel-heading` #3e3523, `--hm-sw-viewer-panel-label` #6e6759, `--hm-sw-viewer-panel-primary` #123328, `--hm-sw-viewer-panel-switch-off` = `var(--hm-sw-property-inner-border)`, `--hm-sw-viewer-panel-switch-on` #266651, `--hm-sw-viewer-panel-badge-background` = `var(--hm-sw-property-table)`, `--hm-sw-viewer-panel-overlay` `rgba(0, 0, 0, 0.1)`, `--hm-sw-viewer-panel-spacing` 12px | +- **Model / Layout 탭 토큰:** 위 `--hm-sw-viewer-tabs-*`·`--hm-sw-viewer-tab-*`은 탭 컨테이너·버튼에만 연결되며 다른 Header 부품·SWHeader·Footer·패널에 쓰이지 않습니다. 탭 색만 바꾸려면 가장 바깥 SW 범위의 `style`로 덮습니다: 화면을 ``로 감싸거나 단독 `SWViewerHeader`의 루트에 지정합니다(토큰이 그 범위에 선언되므로 일반 `div` 래퍼의 값은 가려짐). 예: `style={{ "--hm-sw-viewer-tab-active-border": "linear-gradient(#e07b00, #e07b00)", "--hm-sw-viewer-tabs-outline-color": "#4a7fd0" }}`. `var(...)` 기본값(`active-text`·`hover-*`·`focus-color`)은 SW 범위에서 계산되므로 공통 토큰(`--hm-sw-viewer-accent` 등)을 바꾸면 같은 범위에서 함께 따라가고, 탭 토큰을 직접 지정하면 공통 토큰과 독립합니다. 글자 외곽선(`text-shadow`)은 탭에서도 기존 그대로(이름 `--hm-sw-header-text-outline` 1.3px, 개수 `--hm-sw-header-label-outline` 1px)이며 탭 토큰이 아닙니다. 탭 class로 색을 덮지 않습니다. - **공유 토큰(모드 혼합 아님):** 뷰어 Header의 파일명·객체 수정·속성 면은 `--hm-sw-header-document-*`, `--hm-sw-header-object-*`, `--hm-sw-header-property-*`를, 글자 외곽선은 `--hm-sw-header-text-outline`·`--hm-sw-header-label-outline`을 그대로 읽습니다(§2·§3). 패널 제목 막대 추가 버튼(`ghost`)의 Hover·Focus 색은 `--hm-sw-header-selected-color`입니다. | 상태 | Header 항목(button·toggle·menu·tabs·switch) | 패널 콘텐츠 | |---|---|---| -| Hover | 글자·아이콘 `--hm-sw-viewer-accent`(#ffc600), 면 없음, 크기·위치 변화 없음(2026-10-21; 상태 `SWViewerStatus`는 Hover 없음) | `outline` 버튼: 면 `--hm-sw-viewer-panel-field-background` / `primary`: 면·테두리 `--hm-sw-viewer-hover` / `tone="green"`: 면·테두리 `--hm-sw-viewer-hover`, 글자 #fff / `ghost`: 글자만 `--hm-sw-header-selected-color` | -| Active·Pressed·열림·선택 탭 | 글자·아이콘 `--hm-sw-viewer-accent`(#ffc600), 선택 박스 없음 | `tone="green"` 누르는 동안: 면·테두리 `--hm-sw-viewer-panel-primary`, 글자 #fff | +| Hover | 글자·아이콘 `--hm-sw-viewer-accent`(#ffc600), 면 없음, 크기·위치 변화 없음(2026-10-21; 상태 `SWViewerStatus`는 Hover 없음). 탭: 비선택 버튼만 글자 `--hm-sw-viewer-tab-hover-text`·테두리 `--hm-sw-viewer-tab-hover-border` | `outline` 버튼: 면 `--hm-sw-viewer-panel-field-background` / `primary`: 면·테두리 `--hm-sw-viewer-hover` / `tone="green"`: 면·테두리 `--hm-sw-viewer-hover`, 글자 #fff / `ghost`: 글자만 `--hm-sw-header-selected-color` | +| Active·Pressed·열림·선택 탭 | 글자·아이콘 `--hm-sw-viewer-accent`(#ffc600), 선택 박스 없음. 선택 탭: 테두리 `--hm-sw-viewer-tab-active-border`, 글자 `--hm-sw-viewer-tab-active-text`, 광택 없음 | `tone="green"` 누르는 동안: 면·테두리 `--hm-sw-viewer-panel-primary`, 글자 #fff | | Checked(스위치) | 면 `--hm-sw-viewer-switch-on`(= `--hm-sw-viewer-hover`), thumb `--hm-sw-viewer-switch-thumb-on`(노란색 없음, 2026-10-22) | 면 `--hm-sw-viewer-panel-switch-on` | -| Focus | 2px solid `--hm-sw-viewer-accent` 외곽선 | 버튼·Input·Slider: 2px solid `--hm-sw-viewer-panel-switch-on` 외곽선 | +| Focus | 2px solid `--hm-sw-viewer-accent` 외곽선(탭: `--hm-sw-viewer-tab-focus-color`, 버튼 안쪽 -2px) | 버튼·Input·Slider: 2px solid `--hm-sw-viewer-panel-switch-on` 외곽선 | | Disabled | `--hm-sw-viewer-disabled-opacity`(0.6) | 버튼·Input·Field 0.5 | | Dropdown 항목 | 면 `--hm-sw-viewer-menu-highlight`, 글자 `--hm-sw-viewer-menu-highlight-text`(Focus·Hover·서브메뉴 열림), Disabled 0.5 | — | | 상태 표시 | `SWViewerStatus`: 글자 `--hm-sw-viewer-muted` 11px 400, `error`는 `--hm-sw-viewer-accent`, `loading` 아이콘 회전 | — | diff --git a/packages/sw/ai/VIEWER.md b/packages/sw/ai/VIEWER.md index 96f6cd7..9a69030 100644 --- a/packages/sw/ai/VIEWER.md +++ b/packages/sw/ai/VIEWER.md @@ -31,7 +31,7 @@ | `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로 이동합니다. 모양(높이·글자·개수·색·field 면)은 그대로 | +| `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, 가장자리 blur 처리한 1px #349f7f 선 — blur는 상자 밖 1px까지, 안쪽 그림자)가 면이 되고, 버튼(컨테이너를 채워 37.5px)은 같은 Gradient 면과 1.428px Gradient 테두리(선택 금색 #a67d03→#fadd4f→#926e03 + 글자 #ffc600, 비선택 #189b6e→#287a5e + 흰 글자 + 상단 초록 광택), 이름 14px 700·개수 14px 400, 간격 14px(버튼 좌우 여백 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, Story `SW/Viewer › Model / Layout`) | | `type: "switch"` | `checked`, `onCheckedChange` (2.5D 등, 위 11px 캡션) | | `type: "custom"` | `render()`, `menuEntries?`(More에 넣을 항목) | | `onCommand(command)` | 모든 명령을 한 곳에서 받습니다: `{ id, itemId, value? }`. 항목 자체 Callback 다음에 호출 | @@ -154,6 +154,6 @@ const groups: SWViewerHeaderGroup[] = [ ## 7. 디자인 변경 시 주의 -- 크기(52px Header, 34px 버튼, 40px 토글·탭·스위치 칸, 41~42px field 면)와 색은 참고 뷰어 실측값이고, 로고 면 64px는 2026-10-23 `SWHeader` 로고 기준입니다. 파일명·객체 수정·속성 면 배경은 SWHeader와 같은 사용자 확정 토큰(`--hm-sw-header-document-*`, `object-*`, `property-*`)을 공유하므로 바꾸면 두 Header가 함께 바뀝니다. +- 크기(52px Header, 34px 버튼, 40px 토글·스위치 칸, 41~42px field 면)와 색은 참고 뷰어 실측값이고(Model / Layout 탭은 2026-10-23 Figma 7652:40344 모양, 높이는 field 그룹과 같은 42px·버튼 37.5px), 로고 면 64px는 2026-10-23 `SWHeader` 로고 기준입니다. 파일명·객체 수정·속성 면 배경은 SWHeader와 같은 사용자 확정 토큰(`--hm-sw-header-document-*`, `object-*`, `property-*`)을 공유하므로 바꾸면 두 Header가 함께 바뀝니다. - **높이:** 패키지는 뷰어(도면) 영역 높이를 정하지 않습니다. Header는 52px 한 줄, 팝업만 최대 90vh입니다. 화면 높이는 앱의 상위 레이아웃이 정하고(예: `html, body, #root { height: 100% }` → 세로 flex, Header 다음 도면 영역 `flex: 1; min-height: 0`), 도면 영역 크기 변화에 맞춰 렌더러 크기를 바꾸는 것은 프로그램 담당입니다(ResizeObserver 권장). Storybook 예시의 고정 높이는 미리보기 프레임 값일 뿐 컴포넌트 값이 아닙니다. - Light/Dark 분기는 없습니다. 참고 뷰어와의 알려진 차이: 도면 공간(tabs) field 면 높이 42px(참고 41px — 참고 Tabs 루트가 39px인 구현 부산물), 크기 조절은 네이티브 `resize` 대신 같은 위치의 모서리 Grip(모든 브라우저 동작), 패널 Escape 닫기 추가, 로고 기본 표시 없음(사용자 결정). diff --git a/packages/sw/src/theme/sw-viewer.css b/packages/sw/src/theme/sw-viewer.css index 7742daf..e186c16 100644 --- a/packages/sw/src/theme/sw-viewer.css +++ b/packages/sw/src/theme/sw-viewer.css @@ -28,6 +28,40 @@ --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; + /* 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). */ + --hm-sw-viewer-tabs-height: calc(var(--hm-sw-viewer-control-height) + 2px); + --hm-sw-viewer-tabs-radius: 6px; + --hm-sw-viewer-tabs-background: rgba(0, 0, 0, 0.8); + /* Outline: a 1px line just outside the frame, blurred (Figma Rectangle 9333: stroke outside + layer blur 2). */ + --hm-sw-viewer-tabs-outline-color: #349f7f; + --hm-sw-viewer-tabs-outline-blur: 1px; + /* Extra glow around the frame: none in Figma (the blurred outline is the glow); set a color to add one. */ + --hm-sw-viewer-tabs-glow-color: transparent; + --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); + /* Buttons (both states share the face): */ + --hm-sw-viewer-tab-radius: 5px; + --hm-sw-viewer-tab-border-width: 1.428px; + --hm-sw-viewer-tab-gap: 14px; + --hm-sw-viewer-tab-background: #0e3d2f; + --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%); + /* Selected (Model in Figma) / not selected (Layout in Figma). Borders are images (Figma gradient strokes, top to + bottom); a solid border is 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); + --hm-sw-viewer-tab-inactive-border: linear-gradient(180deg, #189b6e 0%, #287a5e 100%); + --hm-sw-viewer-tab-inactive-text: var(--hm-sw-viewer-text); + /* Top gloss of the not-selected button: */ + --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; + /* Interaction (no Figma hover: the former text-only #FFC600 hover is kept): */ + --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-font-size: 14px; --hm-sw-viewer-label-size: 11px; --hm-sw-viewer-icon-size: 16px; @@ -353,31 +387,94 @@ button.hm-sw-viewer-header__brand { text-align: left; } .hm-sw-viewer-icon__glyph { position: relative; } .hm-sw-viewer-icon svg { width: 100%; height: 100%; overflow: visible; } -/* Tabs (Model / Layout): 40px triggers with an 11px count; the active one turns #FFC600. - Width (2026-10-23): equal columns (1fr: each at least the widest name + count, so they never overlap). A group +/* Tabs (Model / Layout), Figma 7652:40344 (2026-10-23): a dark container (overlay face with a 1px margin, a blurred + green line outside it, inner top shadow over the buttons) holding buttons 1px apart; each button has the gradient + face, a gold (selected) or green (not selected) gradient border and the name (Bold) + count (Regular) at 14px; the + 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 (outer field face); with less room it stays narrower, and below the natural width the - measured steps move it into More as before (the measurement, `data-measuring`, uses the natural width). */ -.hm-sw-viewer-header__tabs { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; align-items: center; gap: var(--hm-sw-viewer-gap); } -.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); } + --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-header__tabs { + position: relative; + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + align-items: stretch; + gap: 1px; + box-sizing: border-box; + height: var(--hm-sw-viewer-tabs-height); + /* The transparent 1px edge is the room of the blurred outline (it stays inside the box height); the padding is the + black margin around the buttons, which fill the rest. 1.5px at the bottom (2026-10-23 user value): the outline + blur covers part of a 1px bottom margin, while the inner shadow darkens the top, so 1px read as no room below. */ + padding: 1px 1px 1.5px 1px; + border: 1px solid transparent; + border-radius: calc(var(--hm-sw-viewer-tabs-radius) + 1px); + background: var(--hm-sw-viewer-tabs-background) padding-box; + box-shadow: var(--hm-sw-viewer-tabs-glow); +} +/* The outline (blurred, on the frame edge) and the inner shadow lie over the buttons, as the last Figma layers do. */ +.hm-sw-viewer-header__tabs::before { + content: ""; + position: absolute; + z-index: 1; + /* The outer edge of the box (its border box, over the transparent 1px edge row): same size as the tabs box. */ + inset: -1px; + border: 1px solid var(--hm-sw-viewer-tabs-outline-color); + border-radius: inherit; + filter: blur(var(--hm-sw-viewer-tabs-outline-blur)); + /* The blur may soften 1px past that edge, no further (2026-10-23 user decision). */ + clip-path: inset(-1px round calc(var(--hm-sw-viewer-tabs-radius) + 1px)); + 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 + 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 > .hm-sw-viewer-header__tabs:only-child { flex: 1 1 auto; } -.hm-sw-viewer-header .hm-sw-viewer-header__tab { +/* 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. */ +.hm-sw-viewer-header__object > .hm-sw-viewer-header__group:first-child:has(> .hm-sw-viewer-header__tabs:only-child) { + margin-right: 1px; +}.hm-sw-viewer-header .hm-sw-viewer-header__tab { + position: relative; + isolation: isolate; display: inline-flex; align-items: center; justify-content: center; - gap: var(--hm-sw-viewer-gap); - height: var(--hm-sw-viewer-control-height); - padding: 0 8px; - border: 1px solid transparent; - border-radius: var(--hm-sw-viewer-radius); - color: var(--hm-sw-viewer-text); + gap: var(--hm-sw-viewer-tab-gap); + box-sizing: border-box; + padding: 0 5px; /* 6px less the 2px of the tabs edge row and the 1px group margin above, so the natural width (measured steps) is unchanged */ + /* The border is the state's border image painted under a transparent border (keeps the radius). */ + --hm-sw-viewer-tab-border-face: var(--hm-sw-viewer-tab-inactive-border); + border: var(--hm-sw-viewer-tab-border-width) solid transparent; + border-radius: 0; + background: var(--hm-sw-viewer-tab-gradient) padding-box, linear-gradient(var(--hm-sw-viewer-tab-background), var(--hm-sw-viewer-tab-background)) padding-box, var(--hm-sw-viewer-tab-border-face) border-box; + color: var(--hm-sw-viewer-tab-inactive-text); font-weight: 500; - line-height: 1.4; + line-height: 20px; white-space: nowrap; } -.hm-sw-viewer-header .hm-sw-viewer-header__tab:hover:not(:disabled) { color: var(--hm-sw-viewer-accent); } -.hm-sw-viewer-header .hm-sw-viewer-header__tab[data-state="active"] { color: var(--hm-sw-viewer-accent); } +.hm-sw-viewer-header .hm-sw-viewer-header__tab:first-child { border-start-start-radius: var(--hm-sw-viewer-tab-radius); border-end-start-radius: var(--hm-sw-viewer-tab-radius); } +.hm-sw-viewer-header .hm-sw-viewer-header__tab:last-child { border-start-end-radius: var(--hm-sw-viewer-tab-radius); border-end-end-radius: var(--hm-sw-viewer-tab-radius); } +.hm-sw-viewer-header .hm-sw-viewer-header__tab:not([data-state="active"])::before { + content: ""; + position: absolute; + z-index: -1; + inset: 0 0 auto; + height: var(--hm-sw-viewer-tab-highlight-height); + border-radius: inherit; + border-end-start-radius: 0; + border-end-end-radius: 0; + background: linear-gradient(180deg, var(--hm-sw-viewer-tab-highlight-color), var(--hm-sw-viewer-tab-highlight-end)); + opacity: var(--hm-sw-viewer-tab-highlight-opacity); + pointer-events: none; +} +.hm-sw-viewer-header .hm-sw-viewer-header__tab:hover:not(:disabled, [data-state="active"]) { --hm-sw-viewer-tab-border-face: var(--hm-sw-viewer-tab-hover-border); color: var(--hm-sw-viewer-tab-hover-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; } +/* 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 { font-size: inherit; } /* Switch fields (2.5D, 어두운 배경): 11px muted caption above the switch. */ .hm-sw-viewer-header__switch-field { @@ -391,17 +488,21 @@ button.hm-sw-viewer-header__brand { text-align: left; } } .hm-sw-viewer-header__switch-label { color: var(--hm-sw-viewer-muted); font-size: var(--hm-sw-viewer-label-size); font-weight: 400; line-height: 1; white-space: nowrap; } -.hm-sw-viewer-header :is(.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__brand, .hm-sw-viewer-switch):focus-visible { +.hm-sw-viewer-header :is(.hm-sw-viewer-header__button, .hm-sw-viewer-header__brand, .hm-sw-viewer-switch):focus-visible { outline: 2px solid var(--hm-sw-viewer-accent); outline-offset: 2px; } +/* Tabs: inside the button (the neighbor is 1px away and the container shadow lies on top). */ +.hm-sw-viewer-header .hm-sw-viewer-header__tab:focus-visible { z-index: 2; outline: 2px solid var(--hm-sw-viewer-tab-focus-color); outline-offset: -2px; } /* Text and icon stroke (2026-10-19 request): the SWHeader black outline on the viewer header, same tokens and method — an outside outline of the glyph / icon shape, never a box border. Text: 14px = --hm-sw-header-text-outline (1.3px black), 11px captions / counts / status = --hm-sw-header-label-outline (1px black), as SWHeader's 12px labels. Icons: SWViewerIcon (SVG stroke, see the token). Fill color, size, weight and spacing are unchanged; hover / active / open change only the fill, disabled fades the whole button - (opacity) like SWHeader. Dropdown faces (light / beige) and panels get no outline, like SWHeader's More menu. */ + (opacity) like SWHeader. Dropdown faces (light / beige) and panels get no outline, like SWHeader's More menu. + Model / Layout (2026-10-23 user decision): the Figma 7652:40344 look keeps these outlines unchanged (name 1.3px, + count 1px, also at the count's new 14px). */ .hm-sw-viewer-header :is( .hm-sw-viewer-header__brand-mark, .hm-sw-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab ) { diff --git a/packages/sw/tests/text-weight.browser.mjs b/packages/sw/tests/text-weight.browser.mjs index 9670208..c3f197f 100644 --- a/packages/sw/tests/text-weight.browser.mjs +++ b/packages/sw/tests/text-weight.browser.mjs @@ -152,7 +152,8 @@ const labels = vNames.map(v => v.text) check('Viewer: file name, 파일 열기, 전체 보기 · 레이어 · 설정 · 속성, Model · Layout are 14px 700 + 1.3px outline', want.every(w => labels.includes(w)) && labels.some(t => t.startsWith('Model')) && labels.some(t => t.startsWith('Layout')) && vNames.every(v => v.weight === '700' && v.size === '14px' && v.outline === OUTLINE_14), vNames) check('Viewer: 샘플 is no longer a header label', !labels.includes('샘플'), labels) const counts = await styles('.hm-sw-viewer-header >> nth=0 >> .hm-sw-viewer-header__count') -check('Viewer: tab counts keep 11px 400', counts.length > 0 && counts.every(c => c.weight === '400' && c.size === '11px'), counts) +// 2026-10-23 Figma 7652:40344: counts are 14px (the name's size), still 400 with the 1px outline. +check('Viewer: tab counts 14px 400 (Figma 7652:40344)', counts.length > 0 && counts.every(c => c.weight === '400' && c.size === '14px'), counts) // File open button (파일 열기, the logo menu is gone): hover keeps its label in place, size and weight. const openButton = vh.locator('[data-document-open]') await stableOnHover('Viewer 파일 열기', openButton, openButton.locator('.hm-sw-viewer-header__open-label')) diff --git a/packages/sw/tests/viewer.browser.mjs b/packages/sw/tests/viewer.browser.mjs index d840924..ced414e 100644 --- a/packages/sw/tests/viewer.browser.mjs +++ b/packages/sw/tests/viewer.browser.mjs @@ -82,8 +82,8 @@ if (reference && reference !== '-') { await page.close() } // 2026-10-23: the Model / Layout buttons share one width (their field face widens up to 378px with room), so they and -// their field are left out of the size comparison with the reference; height and look are unchanged. -check('geometry tabs: equal widths, 40px high', comparison.package.tab.length === 2 && comparison.package.tab.every(item => item.h === 40 && Math.abs(item.w - comparison.package.tab[0].w) <= 1), comparison.package.tab.map(item => `${item.w}x${item.h}`).join(' ')) +// their field are left out of the size comparison with the reference. Height: the box matches the field group (42px), so the buttons are 37.5px (margin 1px top and sides, 1.5px bottom; geometry rounds to 38). +check('geometry tabs: equal widths, 37.5px high (box = field group 42px)', comparison.package.tab.length === 2 && comparison.package.tab.every(item => item.h === 38 && Math.abs(item.w - comparison.package.tab[0].w) <= 1), comparison.package.tab.map(item => `${item.w}x${item.h}`).join(' ')) if (comparison.reference) { // Same item = same size (positions shift with the file-name text width, which differs by sample data). const noTabs = items => items.filter(item => !item.text.startsWith('Model')) diff --git a/src/sw/sw-viewer.stories.tsx b/src/sw/sw-viewer.stories.tsx index b88333f..868ed47 100644 --- a/src/sw/sw-viewer.stories.tsx +++ b/src/sw/sw-viewer.stories.tsx @@ -1,7 +1,7 @@ -import { useRef, useState, type ReactNode } from "react" +import { useRef, useState, type CSSProperties, type ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" import { - SWHeader, SWHeaderWindowControls, SWViewerButton, SWViewerHeader, SWViewerHint, SWViewerMenu, SWViewerPanel, SWViewerSection, + SWHeader, SWHeaderWindowControls, SWTheme, SWViewerButton, SWViewerHeader, SWViewerHint, SWViewerMenu, SWViewerPanel, SWViewerSection, type SWViewerHeaderGroup, type SWViewerMenuEntry, } from "@hm-design/sw" import "@hm-design/sw/styles.css" @@ -175,6 +175,43 @@ export const States: Story = { }, } +// 2026-10-23 Figma 7652:40344: the Model / Layout tabs alone, each selected state, Disabled and a color token override +// (CSS variables on SWTheme, as a consuming program sets them). Hover / Focus: point at or Tab to a button. +function TabsRow({ initial, disabled = false, style }: { initial: string; disabled?: boolean; style?: CSSProperties }) { + const [value, setValue] = useState(initial) + const groups: SWViewerHeaderGroup[] = [{ + id: "space", label: "도면 공간", surface: "field", items: [ + { type: "tabs", id: "space", label: "모델과 배치 선택", value, disabled, onValueChange: setValue, options: [{ value: "model", label: "Model", count: 17377 }, { value: "layout", label: "Layout", count: 7377 }] }, + ], + }] + const header = + // Overrides go on the outermost SW scope (SWTheme), where the package declares the tokens. + return
{style ? {header} : header}
+} +const TAB_OVERRIDE = { + "--hm-sw-viewer-tabs-outline-color": "#4a7fd0", + "--hm-sw-viewer-tabs-glow-color": "rgba(74, 127, 208, 0.6)", + "--hm-sw-viewer-tab-gradient": "linear-gradient(180deg, #0b1830, #16305c 50%, #0b1830)", + "--hm-sw-viewer-tab-active-border": "linear-gradient(180deg, #b35f00, #ffb347 40%, #b35f00)", + "--hm-sw-viewer-tab-active-text": "#ffb347", + "--hm-sw-viewer-tab-inactive-border": "linear-gradient(#3d6bb3, #3d6bb3)", + "--hm-sw-viewer-tab-inactive-text": "#dbe7ff", + "--hm-sw-viewer-tab-highlight-color": "#5b8fe0", +} as CSSProperties + +export const ModelLayoutTabs: Story = { + name: "Model / Layout", + render: () => ( + + + + + + + + ), +} + const MENU_ENTRIES: SWViewerMenuEntry[] = [ { type: "label", id: "l-file", label: "파일" }, { type: "item", id: "open", label: "파일 열기", icon: , shortcut: "Ctrl+O" }, @@ -326,6 +363,34 @@ export const VariableSize: Story = { }, } +export const Logo: Story = { + name: "Logo", + render: () => { + const demo = useViewerDemo({ initialDocument: SAMPLES[1].name }) + const rows: { title: string; props: { logoSrc?: string; logo?: ReactNode; logoAlt?: string } }[] = [ + { title: "로고 없음 (기본)", props: {} }, + { title: "logoSrc (이미지 URL)", props: { logoSrc: placeholderLogo, logoAlt: "임시 로고" } }, + { title: "logo (요소)", props: { logo: , logoAlt: "예시 로고" } }, + ] + return ( + + +
+ {rows.map(row => ( + + {/* 1440px is a maximum (2026-10-22): no scroller; the header fits the available width. */} +
+ +
+
+ ))} + +
+
+ ) + }, +} + export const Responsive: Story = { name: "Responsive More", render: () => { @@ -391,34 +456,6 @@ export const CompareWithSWHeader: Story = { ), } -export const Logo: Story = { - name: "Logo", - render: () => { - const demo = useViewerDemo({ initialDocument: SAMPLES[1].name }) - const rows: { title: string; props: { logoSrc?: string; logo?: ReactNode; logoAlt?: string } }[] = [ - { title: "로고 없음 (기본)", props: {} }, - { title: "logoSrc (이미지 URL)", props: { logoSrc: placeholderLogo, logoAlt: "임시 로고" } }, - { title: "logo (요소)", props: { logo: , logoAlt: "예시 로고" } }, - ] - return ( - - -
- {rows.map(row => ( - - {/* 1440px is a maximum (2026-10-22): no scroller; the header fits the available width. */} -
- -
-
- ))} - -
-
- ) - }, -} - function ViewerOnly() { const demo = useViewerDemo({ initialDocument: SAMPLES[1].name }) // 2026-10-23: the same placeholder logo as the SWHeader row, so the two logo slots compare at the same width.