feat(sw): refine viewer model layout design and theme tokens
Deploy Storybook / deploy (push) Successful in 1m13s

- Model / Layout tabs follow Figma 7652:40344: gradient faces, gold / green
  gradient borders, blurred outline, inner shadow and top gloss, with every
  color in its own --hm-sw-viewer-tab(s)-* token (consumer-overridable)
- Box height matches the adjacent field group (42px, from the control height
  token); padding 1px 1px 1.5px, button side padding 5px, 1px right margin
  for a tabs-only first group; 378px max width, equal buttons and the
  measured More steps are unchanged; existing text-shadow kept
- Storybook: SW/Viewer > Model / Layout story (states and token override);
  Logo story listed before Responsive More
- AI docs (VIEWER.md, DESIGN_RULES.md) and viewer / text-weight test
  expectations updated
This commit is contained in:
shkim4 committed 2026-10-23 16:27:41 +09:00
1 parent 7a5236f3ad
commit 325ec25e37
6 files changed
+197 -56

No files matched your search

+5 -3
View File
@@ -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`로 덮습니다: 화면을 `<SWTheme 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` 아이콘 회전 | — |
+2 -2
View File
@@ -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 닫기 추가, 로고 기본 표시 없음(사용자 결정).
+119 -18
View File
@@ -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(<color>, <color>). */
--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
) {
+2 -1
View File
@@ -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'))
+2 -2
View File
@@ -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'))
+67 -30
View File
@@ -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 = <SWViewerHeader documentTitle="Model Layout.dwg" groups={groups} />
// Overrides go on the outermost SW scope (SWTheme), where the package declares the tokens.
return <div className="sw-story-scroller" data-tabs-case={initial + (disabled ? "-disabled" : style ? "-override" : "")}><div style={{ width: 1440 }}>{style ? <SWTheme style={style}>{header}</SWTheme> : header}</div></div>
}
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: () => (
<StoryPage>
<StoryPageHeader title="SW Viewer — Model / Layout" />
<StorySection title="Model 선택"><TabsRow initial="model" /></StorySection>
<StorySection title="Layout 선택"><TabsRow initial="layout" /></StorySection>
<StorySection title="Disabled"><TabsRow initial="model" disabled /></StorySection>
<StorySection title="색상 토큰 Override (--hm-sw-viewer-tab(s)-*)"><TabsRow initial="model" style={TAB_OVERRIDE} /></StorySection>
</StoryPage>
),
}
const MENU_ENTRIES: SWViewerMenuEntry[] = [
{ type: "label", id: "l-file", label: "파일" },
{ type: "item", id: "open", label: "파일 열기", icon: <FolderOpen size={16} />, 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: <svg viewBox="0 0 32 17" width="32" height="17" aria-hidden="true"><rect x="0.5" y="0.5" width="31" height="16" rx="3" fill="none" stroke="#fff" /><text x="16" y="12.5" fill="#fff" fontSize="10" fontWeight="700" textAnchor="middle">LOGO</text></svg>, logoAlt: "예시 로고" } },
]
return (
<StoryPage>
<StoryPageHeader title="SW Viewer — 로고" />
<div className="sw-story-stack">
{rows.map(row => (
<StorySection key={row.title} title={row.title}>
{/* 1440px is a maximum (2026-10-22): no scroller; the header fits the available width. */}
<div style={{ width: "100%", maxWidth: 1440 }} data-logo-case={row.title}>
<SWViewerHeader {...row.props} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
</div>
</StorySection>
))}
<Log lines={demo.log} />
</div>
</StoryPage>
)
},
}
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: <svg viewBox="0 0 32 17" width="32" height="17" aria-hidden="true"><rect x="0.5" y="0.5" width="31" height="16" rx="3" fill="none" stroke="#fff" /><text x="16" y="12.5" fill="#fff" fontSize="10" fontWeight="700" textAnchor="middle">LOGO</text></svg>, logoAlt: "예시 로고" } },
]
return (
<StoryPage>
<StoryPageHeader title="SW Viewer — 로고" />
<div className="sw-story-stack">
{rows.map(row => (
<StorySection key={row.title} title={row.title}>
{/* 1440px is a maximum (2026-10-22): no scroller; the header fits the available width. */}
<div style={{ width: "100%", maxWidth: 1440 }} data-logo-case={row.title}>
<SWViewerHeader {...row.props} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
</div>
</StorySection>
))}
<Log lines={demo.log} />
</div>
</StoryPage>
)
},
}
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.