feat(sw): viewer layer bulb, popup toggle switch, footer layout and 1440px story inner
Deploy Storybook / deploy (push) Successful in 1m11s

- SW Viewer layer popup: search + 전체 레이어 fixed, only the list scrolls; the all-layers
  control is the SWLayerVisibilityIcon bulb (role="switch"), one light #d2c8b4 line under it.
- Example H and the layer popup AI docs follow the bulb layout (VIEWER, DESIGN_RULES,
  COMPONENT_CATALOG, COMPOSITION_PATTERNS, AI_GUIDE sync).
- Popup toggle switch (Figma toggle_btn_new 633:75188 / 633:75902): one sw.css face for the
  HM UI Switch in SWPopup and SWViewerSwitch in SWViewerPanel; SW Header switches unchanged.
- SW Footer stories: no inner scrollers, fluid screens; workspace fits the viewport.
- SW Footer category tabs keep their positions when selected (divider slots kept, pocket
  edges drawn over the neighbours' padding).
- Storybook .sb-page-inner: 1440px max from 1920px (was 80vw).
- SW Header equal-split change was cancelled; header CSS unchanged.

Verified: check:ai-docs PASS, viewer test 49 PASS (1 FAIL hover face + timeout pre-existing),
footer-design-review 21 PASS, browser checks of switch/footer/inner.
Not verified: footer-popup-viewer-refinement (needs full Storybook build), text-weight/hover
tests, Switch 37x16 AI docs (deferred).
This commit is contained in:
shkim4 committed 2026-10-22 20:20:00 +09:00
1 parent 258e0a4b68
commit c1549b2b4f
22 files changed
+121 -63

No files matched your search

+1 -1
View File
@@ -225,7 +225,7 @@ import "@hm-design/sw/styles.css"
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(뷰어 모드) → `VIEWER.md`, 예제 `ai/examples/ExampleH-ViewerMode.tsx`
- 재사용: `SWViewerHeader`(`groups` 데이터: `button`·`toggle`·`menu`·`tabs`·`switch`·`custom` 항목, `onCommand`, `trailing`에 `SWViewerStatus`·`SWViewerHeaderButton`. 창 최소화·닫기가 필요한 버전은 `trailing` 맨 끝에 `SWHeaderWindowControls showWindowMove={false}` — VIEWER.md §2.1), Dropdown은 `type: "menu"` 항목의 `entries`(단독·우클릭은 `SWViewerMenu`), 팝업은 `SWViewerPanel` + `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`
- 신규: 뷰어에 없는 패널 콘텐츠 → 소비 프로젝트 컴포넌트를 `SWViewerPanel`의 `children`에, 안쪽은 `SWViewerField` + `SWViewerInput` 등, 외형은 `--hm-sw-viewer-*` 토큰
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기(예외: 위 `trailing` 끝의 `SWHeaderWindowControls` 최소화·닫기), 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 레이어 Popup의 레이어별 표시·숨김을 `SWViewerSwitch`·Checkbox·눈 아이콘으로 만들기(전구 `SWLayerVisibilityIcon` 아이콘 버튼 필수, 전체 레이어는 상단 `SWViewerSwitch` 하나, VIEWER.md §4.1), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기(예외: 위 `trailing` 끝의 `SWHeaderWindowControls` 최소화·닫기), 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 레이어 Popup의 레이어별 표시·숨김을 `SWViewerSwitch`·Checkbox·눈 아이콘으로 만들기(전구 `SWLayerVisibilityIcon` 아이콘 버튼 필수, 전체 레이어도 상단의 같은 전구 버튼 하나, VIEWER.md §4.1), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
+1 -1
View File
@@ -225,7 +225,7 @@ import "@hm-design/sw/styles.css"
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(뷰어 모드) → `VIEWER.md`, 예제 `ai/examples/ExampleH-ViewerMode.tsx`
- 재사용: `SWViewerHeader`(`groups` 데이터: `button`·`toggle`·`menu`·`tabs`·`switch`·`custom` 항목, `onCommand`, `trailing`에 `SWViewerStatus`·`SWViewerHeaderButton`. 창 최소화·닫기가 필요한 버전은 `trailing` 맨 끝에 `SWHeaderWindowControls showWindowMove={false}` — VIEWER.md §2.1), Dropdown은 `type: "menu"` 항목의 `entries`(단독·우클릭은 `SWViewerMenu`), 팝업은 `SWViewerPanel` + `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`
- 신규: 뷰어에 없는 패널 콘텐츠 → 소비 프로젝트 컴포넌트를 `SWViewerPanel`의 `children`에, 안쪽은 `SWViewerField` + `SWViewerInput` 등, 외형은 `--hm-sw-viewer-*` 토큰
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기(예외: 위 `trailing` 끝의 `SWHeaderWindowControls` 최소화·닫기), 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 레이어 Popup의 레이어별 표시·숨김을 `SWViewerSwitch`·Checkbox·눈 아이콘으로 만들기(전구 `SWLayerVisibilityIcon` 아이콘 버튼 필수, 전체 레이어는 상단 `SWViewerSwitch` 하나, VIEWER.md §4.1), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기(예외: 위 `trailing` 끝의 `SWHeaderWindowControls` 최소화·닫기), 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 레이어 Popup의 레이어별 표시·숨김을 `SWViewerSwitch`·Checkbox·눈 아이콘으로 만들기(전구 `SWLayerVisibilityIcon` 아이콘 버튼 필수, 전체 레이어도 상단의 같은 전구 버튼 하나, VIEWER.md §4.1), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
+1 -1
View File
@@ -225,7 +225,7 @@ import "@hm-design/sw/styles.css"
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(뷰어 모드) → `VIEWER.md`, 예제 `ai/examples/ExampleH-ViewerMode.tsx`
- 재사용: `SWViewerHeader`(`groups` 데이터: `button`·`toggle`·`menu`·`tabs`·`switch`·`custom` 항목, `onCommand`, `trailing`에 `SWViewerStatus`·`SWViewerHeaderButton`. 창 최소화·닫기가 필요한 버전은 `trailing` 맨 끝에 `SWHeaderWindowControls showWindowMove={false}` — VIEWER.md §2.1), Dropdown은 `type: "menu"` 항목의 `entries`(단독·우클릭은 `SWViewerMenu`), 팝업은 `SWViewerPanel` + `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`
- 신규: 뷰어에 없는 패널 콘텐츠 → 소비 프로젝트 컴포넌트를 `SWViewerPanel`의 `children`에, 안쪽은 `SWViewerField` + `SWViewerInput` 등, 외형은 `--hm-sw-viewer-*` 토큰
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기(예외: 위 `trailing` 끝의 `SWHeaderWindowControls` 최소화·닫기), 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 레이어 Popup의 레이어별 표시·숨김을 `SWViewerSwitch`·Checkbox·눈 아이콘으로 만들기(전구 `SWLayerVisibilityIcon` 아이콘 버튼 필수, 전체 레이어는 상단 `SWViewerSwitch` 하나, VIEWER.md §4.1), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기(예외: 위 `trailing` 끝의 `SWHeaderWindowControls` 최소화·닫기), 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 레이어 Popup의 레이어별 표시·숨김을 `SWViewerSwitch`·Checkbox·눈 아이콘으로 만들기(전구 `SWLayerVisibilityIcon` 아이콘 버튼 필수, 전체 레이어도 상단의 같은 전구 버튼 하나, VIEWER.md §4.1), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
+1 -1
View File
@@ -225,7 +225,7 @@ import "@hm-design/sw/styles.css"
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(뷰어 모드) → `VIEWER.md`, 예제 `ai/examples/ExampleH-ViewerMode.tsx`
- 재사용: `SWViewerHeader`(`groups` 데이터: `button`·`toggle`·`menu`·`tabs`·`switch`·`custom` 항목, `onCommand`, `trailing`에 `SWViewerStatus`·`SWViewerHeaderButton`. 창 최소화·닫기가 필요한 버전은 `trailing` 맨 끝에 `SWHeaderWindowControls showWindowMove={false}` — VIEWER.md §2.1), Dropdown은 `type: "menu"` 항목의 `entries`(단독·우클릭은 `SWViewerMenu`), 팝업은 `SWViewerPanel` + `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`
- 신규: 뷰어에 없는 패널 콘텐츠 → 소비 프로젝트 컴포넌트를 `SWViewerPanel`의 `children`에, 안쪽은 `SWViewerField` + `SWViewerInput` 등, 외형은 `--hm-sw-viewer-*` 토큰
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기(예외: 위 `trailing` 끝의 `SWHeaderWindowControls` 최소화·닫기), 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 레이어 Popup의 레이어별 표시·숨김을 `SWViewerSwitch`·Checkbox·눈 아이콘으로 만들기(전구 `SWLayerVisibilityIcon` 아이콘 버튼 필수, 전체 레이어는 상단 `SWViewerSwitch` 하나, VIEWER.md §4.1), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기(예외: 위 `trailing` 끝의 `SWHeaderWindowControls` 최소화·닫기), 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 레이어 Popup의 레이어별 표시·숨김을 `SWViewerSwitch`·Checkbox·눈 아이콘으로 만들기(전구 `SWLayerVisibilityIcon` 아이콘 버튼 필수, 전체 레이어도 상단의 같은 전구 버튼 하나, VIEWER.md §4.1), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
+1 -1
View File
@@ -158,7 +158,7 @@ SW **뷰어 모드** 전용 Export입니다(2026-10-19 추가, 참고 DWG/DXF
- **검증 표시:** 뷰어 Export는 이 카탈로그 기준의 독립 소비 프로젝트(Tarball) 브라우저 검증 기록이 없어 ◻입니다. 예제 H는 공개 Export만 사용합니다.
- 상세 크기·상태는 [VIEWER.md](./VIEWER.md) §2~4가 기준입니다. 이 표에 없는 Props(`items`, `content`, `renderBody` 등)는 없습니다.
- **레이어 Popup 가시성(필수, 2026-10-22):** SW Viewer 레이어 Popup에서 레이어별 표시·숨김은 `SWLayerVisibilityIcon`(SW Header 전구)을 담은 아이콘 버튼을 사용한다. 전체 레이어 켜기·끄기는 Popup 상단의 `SWViewerSwitch` 하나(전체 표시일 때만 ON, 일부 표시는 OFF → 누르면 전체 표시)다. 레이어별 표시·숨김을 Switch·Checkbox로 대체하지 않는다. 행 구성·크기·접근성 이름은 [VIEWER.md](./VIEWER.md) §4.1, 예제 H. 그 밖의 `SWViewerSwitch`는 옵션 ON/OFF용입니다.
- **레이어 Popup 가시성(필수, 2026-10-22):** SW Viewer 레이어 Popup에서 레이어별 표시·숨김은 `SWLayerVisibilityIcon`(SW Header 전구)을 담은 아이콘 버튼을 사용한다. 전체 레이어 켜기·끄기도 Popup 상단의 같은 전구 버튼 하나(`role="switch"`, 전체 표시일 때만 켜짐, 일부 표시는 꺼짐 → 누르면 전체 표시)다. 검색·전체 레이어는 고정, 목록만 스크롤. 개별·전체 표시·숨김을 Switch·Checkbox로 대체하지 않는다. 행 구성·크기·접근성 이름은 [VIEWER.md](./VIEWER.md) §4.1, 예제 H. 그 밖의 `SWViewerSwitch`는 옵션 ON/OFF용입니다.
## 10. Footer (`SWFooter*`, SW 일반 모드)
+1 -1
View File
@@ -174,7 +174,7 @@ const groups: SWViewerHeaderGroup[] = [
| 오른쪽 끝 | `trailing`: `SWViewerStatus` + `SWViewerHeaderButton iconOnly` |
| 도구 창 | 비모달 `SWViewerPanel`(드래그·크기 조절·접기 기본). 여러 개면 `inset`·`defaultPosition`으로 겹치지 않게 |
| 확인·정보 | `SWViewerPanel modal` + `footer`의 `SWViewerButton variant="primary"` |
| 패널 내용 | `SWViewerSection` > `SWViewerField` > `SWViewerSwitch` / `SWViewerSlider` / `SWViewerInput`, 목록은 `surface="plain"` + 프로그램 목록, 설명 `SWViewerHint`. **레이어별 표시·숨김은 전구(`SWLayerVisibilityIcon`) 아이콘 버튼(Switch 금지), 전체 레이어는 상단 `SWViewerSwitch` 하나**(VIEWER.md §4.1, 예제 H) |
| 패널 내용 | `SWViewerSection` > `SWViewerField` > `SWViewerSwitch` / `SWViewerSlider` / `SWViewerInput`, 목록은 `surface="plain"` + 프로그램 목록, 설명 `SWViewerHint`. **레이어별·전체 표시·숨김은 전구(`SWLayerVisibilityIcon`) 아이콘 버튼(Switch 금지), 검색·전체 레이어 고정 + 목록만 스크롤**(VIEWER.md §4.1, 예제 H) |
| 반응형 | 고정 너비가 아니라 Header의 실제 가용 폭으로 3단계: 기본(아이콘+텍스트) → `iconOnlyBelow` 항목 모두 아이콘만(툴팁 유지) → `collapseBelow` 항목을 하나씩 More로(값이 큰 것, 같으면 오른쪽 먼저). 넓어지면 역순 복귀. 패널 토글은 `collapseBelow` 없이 아이콘까지만 |
| 우클릭 메뉴 | `SWViewerMenu` + `anchorPoint` |
+1 -1
View File
@@ -161,7 +161,7 @@
| 상태 표시 | `SWViewerStatus`: 글자 `--hm-sw-viewer-muted` 11px 400, `error`는 `--hm-sw-viewer-accent`, `loading` 아이콘 회전 | — |
- **Typography(뷰어):** Header 항목 14px 500, 캡션·개수·상태 11px 400, 패널 필드 라벨 14px 500, Section 제목 12px 700, 패널 버튼·Badge 12px 500, Input 12px. 패널 콘텐츠(2026-10-22): 레이어 이름·속성 행 Label 14px 500, 읽기 전용 속성값 14px(패널 기본 Weight), 설명·Input·Select 값·Placeholder 12px, 별도 `font-family` 지정 없음([VIEWER.md](./VIEWER.md) §4.2). Font Weight는 400/500/700만 씁니다.
- **레이어 Popup 가시성(2026-10-22 필수, 10-21 눈 아이콘 규정 대체):** SW Viewer 레이어 Popup에서 레이어별 표시·숨김은 SW Header 레이어 메뉴와 같은 전구 아이콘(`SWLayerVisibilityIcon`, 20 × 20, 표시 = 색 있는 전구 / 숨김 = 회색) 버튼을 사용한다. 버튼 24 × 24 · radius 4 · Hover 면 `rgba(154, 146, 132, .3)` · `cursor: pointer`, 숨긴 행 불투명도 0.45. 전체 레이어 켜기·끄기는 Popup 상단 `SWViewerSection title="전체 레이어"`(기존 Section 제목) + 같은 줄의 `SWViewerSwitch` 하나(전체 표시일 때만 ON). 레이어별 표시·숨김은 Switch로 대체하지 않는다([VIEWER.md](./VIEWER.md) §4.1).
- **레이어 Popup 가시성(2026-10-22 필수, 10-21 눈 아이콘 규정 대체):** SW Viewer 레이어 Popup에서 레이어별 표시·숨김은 SW Header 레이어 메뉴와 같은 전구 아이콘(`SWLayerVisibilityIcon`, 20 × 20, 표시 = 색 있는 전구 / 숨김 = 회색) 버튼을 사용한다. 버튼 24 × 24 · radius 4 · Hover 면 `rgba(154, 146, 132, .3)` · `cursor: pointer`, 숨긴 행 불투명도 0.45. 전체 레이어 켜기·끄기는 Popup 상단 `SWViewerSection title="전체 레이어"`(기존 Section 제목) + 같은 줄의 같은 전구 버튼 하나(`role="switch"`, 전체 표시일 때만 켜짐, 행 전구와 세로 정렬). 검색·전체 레이어 행은 고정, 목록만 세로 스크롤; 검색과 전체 레이어 사이 구분선 없음, 전체 레이어 행 아래 1px `#d2c8b4`(`--hm-sw-property-table`) + 아래 여백 8px. 개별·전체 표시·숨김은 Switch로 대체하지 않는다([VIEWER.md](./VIEWER.md) §4.1).
- **뷰어 신규 컴포넌트 체크리스트:** ① VIEWER.md·카탈로그 §9 컴포넌트로 해결되는지 먼저 본다 ② 위 토큰을 `var(--…)`로만 읽고 일반 모드 전용 모양(§8 `SWPopupRow`, §9 `SWToolButton`)을 섞지 않는다 ③ 상태는 위 표대로 ④ 토큰에 없는 값은 결과 보고에 "AI 구성 값 — 디자인 확인 필요"로 적는다 ⑤ `hm-sw-viewer-*` class를 쓰거나 덮어쓰지 않는다.
## 12. 로고 클릭 메뉴 (`SWHeader homeMenu` / `SWHomeMenu`, Figma 26:10591) — SW 일반 모드 전용
+6 -6
View File
@@ -83,7 +83,7 @@
|---|---|
| `title`, `open`, `onClose`, `closeLabel?` | 필수는 `title`·`open`·`onClose`. 제목 아이콘은 없습니다(SW 팝업 디자인) |
| `children` | 스크롤 본문. `SWViewerSection`(카드/plain, 12px 제목), `SWViewerField`(라벨 + 컨트롤, `inline` / `stacked` + `below`), `SWViewerSwitch`, `SWViewerSlider`, `SWViewerInput`, `SWViewerButton`, `SWViewerBadge`, `SWViewerHint`, 프로그램 목록·표 등 무엇이든 |
| `footer?` | 하단 버튼 영역(본문 스크롤과 무관하게 고정). `SWViewerButton variant="outline" \| "primary"`. `tone="green"` = 기본은 그대로, Hover는 primary(확인) 버튼 Hover와 같은 토큰 `--hm-sw-viewer-hover`, 누르는 동안 primary 면 `--hm-sw-viewer-panel-primary` (레이어 전체 표시는 버튼이 아니라 §4.1의 전체 레이어 Switch) |
| `footer?` | 하단 버튼 영역(본문 스크롤과 무관하게 고정). `SWViewerButton variant="outline" \| "primary"`. `tone="green"` = 기본은 그대로, Hover는 primary(확인) 버튼 Hover와 같은 토큰 `--hm-sw-viewer-hover`, 누르는 동안 primary 면 `--hm-sw-viewer-panel-primary` (레이어 전체 표시는 footer 버튼이 아니라 §4.1의 전체 레이어 전구) |
| `headerActions?` | 제목 막대 추가 버튼: `SWViewerButton variant="ghost"` (26px, 글자색만 Hover #FFC600 — SW 제목 막대 방식) |
| `width?`(314 = SWPopup), `height?`(420, `"auto"` 가능), `minWidth?`(250), `minHeight?`(140) | 메뉴별 크기. 최대 90vw × 90vh |
| `defaultPosition?` | `{ left, top }` 또는 `"top-end"`(기본) / `"top-start"` / `"center"`(modal 기본), `inset?: { top?, side? }`(기본 56 / 24 = 52px Header 아래) |
@@ -97,15 +97,15 @@
- **콘텐츠 교체:** 같은 프레임에 다른 `children`을 넣습니다(레이어 목록 / 옵션 / 속성 / 입력 폼 — Storybook `SW/Viewer › Settings Popup`). 프레임을 복사하거나 `hm-sw-viewer-*` class로 새 팝업을 만들지 않습니다.
- **본문 하단 경계(2026-10-22):** 뷰어 패널 본문은 아래 7px 여백 대신 스크롤 영역 밖의 8px 투명 경계를 둡니다(패키지 CSS, `padding-bottom: 0` + `border-bottom: 8px solid transparent`). 스크롤된 목록 행은 테두리 8px 위에서 잘리고, 맨 아래로 스크롤하면 마지막 행이 다 보입니다. 소비 코드에서 하단 여백·`padding`을 덧대지 않습니다. 일반 `SWPopup`은 7px 그대로입니다.
### 4.1 레이어 Popup — 개별 표시·숨김은 전구 아이콘 버튼, 전체는 Switch (필수, 2026-10-22)
### 4.1 레이어 Popup — 개별·전체 표시·숨김 모두 전구 아이콘 버튼 (필수, 2026-10-22)
> **규칙:** SW Viewer 레이어 Popup에서 레이어별 표시·숨김은 SW Header 레이어 메뉴와 같은 전구 아이콘(`SWLayerVisibilityIcon`) 버튼을 사용한다(2026-10-21 눈 `Eye`/`EyeOff` 규정 대체). 전체 레이어 켜기·끄기는 Popup 상단의 `SWViewerSwitch` 하나로 제어한다. 레이어별 표시·숨김을 Switch·Checkbox로 대체하지 않는다.
> **규칙:** SW Viewer 레이어 Popup에서 레이어별 표시·숨김은 SW Header 레이어 메뉴와 같은 전구 아이콘(`SWLayerVisibilityIcon`) 버튼을 사용한다(2026-10-21 눈 `Eye`/`EyeOff` 규정 대체). 전체 레이어 켜기·끄기도 Popup 상단의 같은 전구 버튼(`SWLayerVisibilityIcon`) 하나로 제어한다(2026-10-22 `SWViewerSwitch` 규정 대체). 개별·전체 표시·숨김을 Switch·Checkbox로 대체하지 않는다.
- 패키지에는 레이어 목록 전용 컴포넌트가 없습니다. 목록은 `SWViewerPanel` > `SWViewerSection surface="plain"` 안에 프로그램이 두고, 위에서부터 검색(선택) · **전체 레이어 Switch** · 목록 순서입니다. 행은 색 점 · 레이어 이름 · 개수(`SWViewerBadge`) · **전구 아이콘 버튼** 순서입니다(Storybook `SW/Viewer › Viewer Header`의 레이어 Popup, 예제 [ExampleH-ViewerMode.tsx](./examples/ExampleH-ViewerMode.tsx)).
- 패키지에는 레이어 목록 전용 컴포넌트가 없습니다. 목록은 `SWViewerPanel` > `SWViewerSection surface="plain"` 안에 프로그램이 두고, 위에서부터 검색(선택) · **전체 레이어 전구** · 목록 순서입니다. 검색과 전체 레이어 행은 고정이고 **목록만 세로 스크롤**합니다: 바깥 Section(`app-…`)에 `flex: 1 1 auto; min-height: 0`, 목록에 `flex: 1 1 auto; min-height: 0; overflow-y: auto; scrollbar-gutter: stable`, 나머지 자식은 `flex-shrink: 0`(소비 앱 class, 예제 `example-layout.css`). 패널 본문 자체는 스크롤되지 않으며 팝업 크기(기본 314 × 420)는 그대로입니다. 검색과 전체 레이어 사이에는 구분선이 없고, 전체 레이어 행 아래에만 연한 1px 선 `#d2c8b4`(`--hm-sw-property-table`, `padding-bottom: 8px`)를 둡니다. 행은 색 점 · 레이어 이름 · 개수(`SWViewerBadge`) · **전구 아이콘 버튼** 순서입니다(Storybook `SW/Viewer › Viewer Header`의 레이어 Popup, 예제 [ExampleH-ViewerMode.tsx](./examples/ExampleH-ViewerMode.tsx)).
- 전구 아이콘 버튼: 프로그램의 `<button type="button">` 24 × 24, radius 4, Hover 면 `rgba(154, 146, 132, .3)`, `cursor: pointer` 안에 `<SWLayerVisibilityIcon on={visible} />`(20 × 20, 장식용). 표시 중이면 색 있는 전구, 숨김이면 회색 전구(Header 레이어 메뉴와 같은 그림). 숨긴 행은 불투명도 0.45. 아이콘을 직접 그리거나 `hm-sw-header__*` class를 붙이지 않습니다.
- 접근성 이름에 다음 동작을 넣습니다: 표시 중 `"<레이어> 숨기기"`, 숨김 `"<레이어> 표시하기"`(`title`도 같은 동사).
- 클릭하면 그 레이어 하나의 가시성만 바꾸고 프로그램의 가시성 Callback·상태를 그대로 호출합니다. 아이콘만 그리고 기능을 빼지 않습니다.
- 전체 레이어 Switch(2026-10-22 제목 형식): `<SWViewerSection surface="plain" title="전체 레이어" className="app-…"><SWViewerSwitch aria-label="전체 레이어" checked={allVisible} onCheckedChange={setAll} /></SWViewerSection>`. 제목은 기존 Section 제목(`.hm-sw-viewer-section__title`, 12px 700)을 그대로 쓰고 별도 제목 CSS·Prop을 만들지 않습니다. 제목과 Switch를 한 줄에 두는 것은 소비 앱 자기 class(예: `section.app-viewer-all-layers { flex-direction: row; align-items: center; justify-content: space-between; min-height: 20px }`, 예제 `example-layout.css`)로 하고 `hm-sw-viewer-*` class는 덮지 않습니다. `checked`는 모든 레이어가 표시 중일 때만 `true`이고, 일부만 표시 중이면 `false`로 보이며 누르면 전체 표시가 됩니다. ON → 전체 표시, OFF → 전체 숨김. 별도 상태를 두지 않고 레이어 가시성 상태에서 계산하므로 개별 전환 시 자동으로 맞춰집니다. 이전의 `전체 켜기`·`전체 끄기` 버튼 두 개는 쓰지 않습니다.
- 전체 레이어 전구(2026-10-22): `<SWViewerSection surface="plain" title="전체 레이어" className="app-…"><button type="button" role="switch" aria-checked={allVisible} aria-label="전체 레이어" className="app-…-bulb" onClick={() => setAll(!allVisible)}><SWLayerVisibilityIcon on={allVisible} /></button></SWViewerSection>`. 버튼은 행의 전구 버튼과 같은 class·크기(24 × 24)이고 전구를 따로 그리지 않습니다. `role="switch"`·`aria-checked`·이름 `전체 레이어`로 켜짐 상태를 알리며 Enter·Space는 기본 button 동작입니다. 행 전구와 세로로 맞추려면 이 행에 목록과 같은 `padding-right: 6px` + `overflow-y: hidden; scrollbar-gutter: stable`을 주고 포커스 링은 `outline-offset: -2px`로 버튼 안에 둡니다. 제목은 기존 Section 제목(`.hm-sw-viewer-section__title`, 12px 700)을 그대로 쓰고 별도 제목 CSS·Prop을 만들지 않습니다. 제목과 전구를 한 줄에 두는 것은 소비 앱 자기 class(예: `section.app-viewer-all-layers { flex-direction: row; align-items: center; justify-content: space-between; min-height: 20px }`, 예제 `example-layout.css`)로 하고 `hm-sw-viewer-*` class는 덮지 않습니다. 켜짐(`aria-checked`, 색 있는 전구)은 모든 레이어가 표시 중일 때만 `true`이고, 일부만 표시 중이면 `false`(회색 전구)로 보이며 누르면 전체 표시가 됩니다. 켜진 전구를 누르면 전체 숨김. 별도 상태를 두지 않고 레이어 가시성 상태에서 계산하므로 개별 전환 시 자동으로 맞춰집니다. 이전의 `전체 켜기`·`전체 끄기` 버튼 두 개는 쓰지 않습니다.
- 그 밖의 `SWViewerSwitch`는 옵션 패널의 ON/OFF 설정(그리드·2.5D 등)에 씁니다.
- **옵션(설정) 패널(2026-10-22):** 하단 `취소`·`적용` 버튼이 없습니다(`footer` 미사용 → Footer 영역·구분선도 없음). 토글·Input·Slider는 바뀌는 즉시 프로그램 상태에 반영하고, 기본값 복원은 제목 막대의 초기화(`headerActions`) 버튼입니다. 확인이 필요한 입력 팝업(축척 지정 등)의 `footer`는 그대로 씁니다.
@@ -146,7 +146,7 @@ const groups: SWViewerHeaderGroup[] = [
| Header 버튼 글자에 `-webkit-text-stroke`·`border`로 외곽선 만들기 | 외곽선은 패키지 토큰이 이미 적용합니다 |
| 메뉴 색·간격을 `#123456`, `margin` 등 임의 값으로 덮기 | 토큰 `--hm-sw-viewer-*` / `--hm-sw-header-*`만 가장 바깥 SW 범위(`<SWTheme>` 또는 단독 컴포넌트 루트)의 `style`로 조정(기본값 유지 권장, [AI_USAGE.md](./AI_USAGE.md) §3.1) |
| Header·Dropdown·More·Panel의 글꼴을 각각 따로 지정, `.hm-sw-viewer-menu` 등 class로 덮기 | 화면을 `<SWTheme style={theme}>` 하나로 감싸면 Header·Dropdown·More·Panel이 같은 값을 상속합니다. 글꼴 기본값은 Noto Sans KR이며 Web UI 글꼴 토큰을 복사하지 않습니다 |
| 레이어 Popup의 레이어별 표시·숨김을 `SWViewerSwitch`·Checkbox·ON/OFF 스위치·눈 아이콘으로 만들기 | §4.1: 전구(`SWLayerVisibilityIcon`) 아이콘 버튼. Switch는 상단 전체 레이어 제어와 옵션 설정에만 |
| 레이어 Popup의 개별·전체 표시·숨김을 `SWViewerSwitch`·Checkbox·ON/OFF 스위치·눈 아이콘으로 만들기 | §4.1: 전구(`SWLayerVisibilityIcon`) 아이콘 버튼(전체 레이어 포함). Switch는 옵션 설정에만 |
| 존재하지 않는 Props 추측(`items`, `content`, `renderBody` …) | 위 표와 `dist/index.d.ts`가 전부 |
| 일반 모드 화면에서 `SWViewerHeader` 사용, 뷰어에서 `SWHeader` 재스타일 | 모드별 Header를 바꿔 끼웁니다(창 최소화·닫기 `SWHeaderWindowControls`를 `trailing` 끝에 두는 §2.1 구성은 허용) |
| 기능 없는 메뉴를 가짜 동작으로 연결 | 항목 제외 또는 `disabled` + 미연결 보고 |
@@ -36,8 +36,10 @@ export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => vo
setHiddenLayers(next)
onEvent?.(`layer:${name}=${!visible}`)
}
// All-layers switch: ON only when every layer is shown. A partial state reads OFF, so turning it on shows all.
// All-layers bulb: ON only when every layer is shown. A partial state reads OFF, so turning it on shows all.
const allVisible = hiddenLayers.size === 0
const [query, setQuery] = useState("")
const shownLayers = layers.filter(layer => layer.name.toLowerCase().includes(query.trim().toLowerCase()))
const setAllLayers = (visible: boolean) => {
setHiddenLayers(new Set(visible ? [] : layers.map(layer => layer.name)))
onEvent?.(`layer:all=${visible}`)
@@ -74,14 +76,17 @@ export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => vo
<SWViewerHeaderButton iconOnly icon={icons.info} label="정보" onClick={() => setAbout(true)} />
</>} />
<SWViewerPanel title="레이어" open={panels.layers} onClose={() => setPanels({ ...panels, layers: false })}>
<SWViewerSection surface="plain" aria-label="레이어 목록">
{/* Top: all-layers SWViewerSwitch. Rows: swatch · name · count · bulb button (SWLayerVisibilityIcon), not a switch. */}
<SWViewerPanel title={`레이어 (${layers.length}개)`} open={panels.layers} onClose={() => setPanels({ ...panels, layers: false })}>
<SWViewerSection surface="plain" aria-label="레이어 목록" className="app-viewer-layer-popup">
{/* Fixed: search, then the all-layers bulb (same button + SWLayerVisibilityIcon as the rows, role="switch").
Only the list below scrolls. Rows: swatch · name · count · bulb button, never a switch. */}
<SWViewerInput type="search" className="app-viewer-layer-search" placeholder="레이어 검색" aria-label="레이어 검색" value={query} onChange={event => setQuery(event.currentTarget.value)} />
<SWViewerSection surface="plain" title="전체 레이어" className="app-viewer-all-layers">
<SWViewerSwitch aria-label="전체 레이어" checked={allVisible} onCheckedChange={setAllLayers} />
<button type="button" role="switch" aria-checked={allVisible} aria-label="전체 레이어" className="app-viewer-layer-bulb"
title={allVisible ? "전체 숨기기" : "전체 표시하기"} onClick={() => setAllLayers(!allVisible)}><SWLayerVisibilityIcon on={allVisible} /></button>
</SWViewerSection>
<ul className="app-viewer-layers">
{layers.map(layer => {
{shownLayers.length === 0 ? <SWViewerHint>검색 결과가 없습니다 ("{query}")</SWViewerHint> : <ul className="app-viewer-layers">
{shownLayers.map(layer => {
const visible = !hiddenLayers.has(layer.name)
return (
<li key={layer.name} data-visible={visible}>
@@ -94,7 +99,7 @@ export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => vo
</li>
)
})}
</ul>
</ul>}
</SWViewerSection>
</SWViewerPanel>
+13 -3
View File
@@ -36,13 +36,23 @@
}
/* Viewer layer list (example H), the same look as Storybook SW/Viewer's layer popup. Panel tokens only.
Layer visibility is a 24 x 24 button around SWLayerVisibilityIcon (header bulb), never a switch or checkbox;
the all-layers control above the list is SWViewerSection title="전체 레이어" + SWViewerSwitch on one row. */
the all-layers control above the list is SWViewerSection title="전체 레이어" + the same bulb button on one row.
Search and that row stay fixed; only the list scrolls (the section fills the panel body). */
.app-viewer-layer-popup { flex: 1 1 auto; min-height: 0; }
.app-viewer-layer-popup > :not(.app-viewer-layers) { flex-shrink: 0; }
/* Search spans the popup (the panel input is 80px by default). */
.app-viewer-layer-popup > input.app-viewer-layer-search { width: 100%; }
.app-viewer-layer-popup > .app-viewer-layers { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
.app-viewer-layers { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
.app-viewer-layers > li { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 3px; }
.app-viewer-layers > li:hover { background: rgba(154, 146, 132, 0.25); }
.app-viewer-layers > li[data-visible="false"] { opacity: 0.45; }
/* 전체 레이어: the plain SWViewerSection laid out as one row — title (.hm-sw-viewer-section__title) left, switch right. */
section.app-viewer-all-layers { flex-direction: row; align-items: center; justify-content: space-between; min-height: 20px; }
/* 전체 레이어: the plain SWViewerSection laid out as one row — title (.hm-sw-viewer-section__title) left, bulb right.
No line between search and this row; one light line (#d2c8b4, --hm-sw-property-table) under it. The bulb lines up
with the row bulbs: row padding (6px) + the list's scrollbar gutter (needs overflow, so the focus ring sits inside). */
section.app-viewer-all-layers { flex-direction: row; align-items: center; justify-content: space-between; min-height: 20px;
padding: 0 6px 8px 0; border-bottom: 1px solid var(--hm-sw-property-table); overflow-y: hidden; scrollbar-gutter: stable; }
.app-viewer-all-layers .app-viewer-layer-bulb:focus-visible { outline-offset: -2px; }
.app-viewer-layer-swatch { flex: 0 0 10px; width: 10px; height: 10px; border: 1px solid var(--hm-sw-viewer-panel-inner-border); border-radius: 9999px; }
.app-viewer-layer-name { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 14px; font-weight: 500; }
.app-viewer-layer-bulb { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 4px; background: none; cursor: pointer; }
@@ -321,7 +321,8 @@ export const SWFooterMenu = forwardRef<HTMLDivElement, SWFooterMenuProps>(functi
const selected = item.id === current
const previousSelected = index > 0 && items[index - 1].id === current
return [
index > 0 && !selected && !previousSelected && <span key={`${item.id}:divider`} className="hm-sw-footer-menu__divider" aria-hidden="true" />,
// 2026-10-22: the divider slot stays next to the current tab (hidden) so selecting never moves a tab.
index > 0 && <span key={`${item.id}:divider`} className="hm-sw-footer-menu__divider" data-pocket={selected || previousSelected || undefined} aria-hidden="true" />,
<button key={item.id} type="button" role="tab" id={`${prefix}-${item.id}`} className="hm-sw-footer-menu__tab"
aria-selected={selected} aria-controls={panelId} tabIndex={item.id === roving ? 0 : -1} disabled={item.disabled}
onClick={() => { if (!selected) onValueChange?.(item.id) }}>
@@ -340,7 +341,7 @@ export const SWFooterMenu = forwardRef<HTMLDivElement, SWFooterMenuProps>(functi
</button>,
]}
{onAdd && [
!steps && items.length > 0 && items[items.length - 1].id !== current && <span key="add:divider" className="hm-sw-footer-menu__divider" aria-hidden="true" />,
!steps && items.length > 0 && <span key="add:divider" className="hm-sw-footer-menu__divider" data-pocket={items[items.length - 1].id === current || undefined} aria-hidden="true" />,
<button key="add" type="button" className="hm-sw-footer-menu__add" aria-label={addLabel} title={addLabel} onClick={onAdd} />,
]}
</div>
@@ -11,8 +11,8 @@ export interface SWViewerSwitchProps extends Omit<ButtonHTMLAttributes<HTMLButto
}
/**
* 36 x 20 switch. Its face follows where it is placed: dark header face (off #0A221D, on #FFC600) inside
* SWViewerHeader, beige panel face (off #9A9284, on #266651) inside SWViewerPanel.
* Switch. Its face follows where it is placed: dark header face (36 x 20, off #0A221D, on #FFC600) inside
* SWViewerHeader, the SW popup toggle face (37 x 16, Figma toggle_btn_new, sw.css) inside SWViewerPanel.
*/
export const SWViewerSwitch = forwardRef<HTMLButtonElement, SWViewerSwitchProps>(function SWViewerSwitch(
{ checked, onCheckedChange, className, onClick, ...rest }, ref,
+5 -2
View File
@@ -394,8 +394,11 @@
.hm-sw-footer-menu__tab:is(:hover:not(:disabled), [aria-selected="true"]) { color: var(--hm-sw-footer-accent); }
/* 1px × 10px black divider (Rectangle 7480 …) between tabs; the current tab has its 7px pocket edges instead. */
.hm-sw-footer-menu__divider { position: relative; flex: 0 0 1px; width: 1px; height: 10px; margin-top: 7px; background: var(--hm-sw-footer-menu-divider); }
.hm-sw-footer-menu__tab[aria-selected="true"] { margin-right: 7px; }
.hm-sw-footer-menu__tab[aria-selected="true"]:not(:first-of-type) { margin-left: 7px; }
/* 2026-10-22: selecting a tab never moves text or tabs. The row keeps every 1px divider slot (hidden beside the
current tab) and the current tab gets no margins: its 7px pocket edges are drawn over the neighbours' 8px padding.
The last tab always keeps 6px on its right so its pocket edge (6px + the 1px slot) stays clear of + / ◀ ▶. */
.hm-sw-footer-menu__divider[data-pocket] { visibility: hidden; }
.hm-sw-footer-menu__tab:last-of-type { margin-right: 6px; }
.hm-sw-footer-menu__add { position: relative; flex: 0 0 28px; width: 28px; height: 12px; margin-top: 6px; background: url("../components/sw-footer/assets/menu-add.svg") 0 0 / 28px 12px no-repeat; }
/* Not in Figma: only Default of btn_add (26:9451) exists. Hover (2026-10-20, user request): the + in the footer accent
(#FF5C00, same black outline), like the category tabs next to it. */
+1 -3
View File
@@ -490,9 +490,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-switch[data-state="checked"] { background: var(--hm-sw-viewer-switch-on); }
.hm-sw-viewer-switch[data-state="checked"] .hm-sw-viewer-switch__thumb { transform: translateX(16px); background: var(--hm-sw-viewer-switch-thumb-on); }
.hm-sw-theme .hm-sw-viewer-switch:disabled { opacity: var(--hm-sw-viewer-disabled-opacity); cursor: not-allowed; }
.hm-sw-viewer-panel .hm-sw-viewer-switch { background: var(--hm-sw-viewer-panel-switch-off); box-shadow: none; }
.hm-sw-viewer-panel .hm-sw-viewer-switch .hm-sw-viewer-switch__thumb { background: #fff; }
.hm-sw-viewer-panel .hm-sw-viewer-switch[data-state="checked"] { background: var(--hm-sw-viewer-panel-switch-on); }
/* Panel switches: the SW popup toggle face in sw.css (Figma toggle_btn_new, 2026-10-22). */
@media (prefers-reduced-motion: reduce) { .hm-sw-viewer-switch, .hm-sw-viewer-switch__thumb { transition: none; } }
/* ---------- Dropdown ---------- */
+32
View File
@@ -88,6 +88,13 @@
--hm-sw-property-label: #000;
--hm-sw-property-table: #d2c8b4;
--hm-sw-property-inner-border: #948871;
/* Popup toggle switch (Figma 이지빔-SW toggle_btn_new: off 633:75188, on 633:75902, 2026-10-22). */
--hm-sw-switch-off-background: #c7bda9;
--hm-sw-switch-off-border: #8a7b5e;
--hm-sw-switch-on-background: #f7efdf;
--hm-sw-switch-on-border: #d2570b;
--hm-sw-switch-thumb-off: linear-gradient(180deg, #fbedd4 0%, #eee3da 100%);
--hm-sw-switch-thumb-on: linear-gradient(180deg, #e8741e 0%, #c0520d 100%);
/* Popup scrollbar (every SW popup body: SWPopup, SWViewerPanel, property window). Same as the DWG/DXF viewer
panels (thin, HM warm-neutral-mid thumb on warm-neutral-light track). */
--hm-sw-popup-scrollbar-thumb: #9a9284;
@@ -1590,6 +1597,31 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-property__body { display: flex; flex-direction: column; gap: 8px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 7px 7px 7px; box-shadow: inset 0 2px 2px rgba(255,255,255,.6); }
/* Popup scrollbar: the body and any scrolling area inside it (zero-specificity so content can still override). */
.hm-sw-property__body, .hm-sw-property__body :where(*) { scrollbar-width: thin; scrollbar-color: var(--hm-sw-popup-scrollbar-thumb) var(--hm-sw-popup-scrollbar-track); }
/* Popup toggle switch (Figma toggle_btn_new 633:75188 / 633:75902): one face for every switch inside an SW popup —
the HM UI Switch in SWPopup and SWViewerSwitch in SWViewerPanel (both carry .hm-sw-property). SW Header switches
are outside this scope and keep their own face. 37 x 16 track, 12 x 12 thumb 2px from the edge; the border is an
inset ring so off (1px) and on (0.8px) keep the same geometry. Disabled / focus: not in Figma, the owners' rules
(opacity, cursor) stay; the focus outline uses the on border color because the ring shadow is replaced. */
.hm-sw-property [role="switch"]:is([data-slot="switch"], .hm-sw-viewer-switch) {
box-sizing: border-box; display: inline-flex; align-items: center; flex-shrink: 0;
width: 37px; height: 16px; padding: 0; border: 0; border-radius: 100px;
background: var(--hm-sw-switch-off-background);
box-shadow: inset 1px 0 2px rgba(0, 0, 0, .3), inset 0 2px 2px rgba(0, 0, 0, .3), inset 0 0 0 1px var(--hm-sw-switch-off-border), 1px 1px 1px #fcf0d8;
}
.hm-sw-property [role="switch"]:is([data-slot="switch"], .hm-sw-viewer-switch)[data-state="checked"] {
background: var(--hm-sw-switch-on-background);
box-shadow: inset 1px 0 1px 1px rgba(0, 0, 0, .2), inset 0 1px 1px 1px rgba(0, 0, 0, .2), inset 0 0 0 .8px var(--hm-sw-switch-on-border), 1px 1px 1px #fcf0d8;
}
.hm-sw-property [role="switch"]:is([data-slot="switch"], .hm-sw-viewer-switch):hover:not(:disabled) { background-image: none; }
.hm-sw-property [role="switch"]:is([data-slot="switch"], .hm-sw-viewer-switch):focus-visible { outline: 2px solid var(--hm-sw-switch-on-border); outline-offset: 1px; }
.hm-sw-property [role="switch"] > :is([data-slot="switch-thumb"], .hm-sw-viewer-switch__thumb) {
width: 12px; height: 12px; border-radius: 6.5px;
background: var(--hm-sw-switch-thumb-off); box-shadow: 0 .5px 1px rgba(0, 0, 0, .6);
transform: translateX(2px);
}
.hm-sw-property [role="switch"][data-state="checked"] > :is([data-slot="switch-thumb"], .hm-sw-viewer-switch__thumb) {
background: var(--hm-sw-switch-thumb-on); transform: translateX(23px);
}
.hm-sw-property[data-selection-kind="all"] .hm-sw-property__body { padding-bottom: 0; }
.hm-sw-property[data-selection-kind="all"] .hm-sw-property__counts { margin-top: -8px; }
.hm-sw-property__group { flex: 0 0 auto; }
+3 -2
View File
@@ -183,14 +183,15 @@ if (comparison.reference) {
check('collapse hides body', !(await layerPanel.locator('.hm-sw-property__body').isVisible()))
await title.dblclick({ position: { x: 30, y: 15 } })
check('title double click expands', await layerPanel.locator('.hm-sw-property__body').isVisible())
// Panel content works. 2026-10-22: per-layer bulb buttons (SWLayerVisibilityIcon) + one all-layers switch on top
// (ON only when every layer is shown; a partial state reads OFF and a click shows all).
// Panel content works. 2026-10-22: per-layer bulb buttons (SWLayerVisibilityIcon) + one all-layers bulb on top
// (role="switch"; ON only when every layer is shown; a partial state reads OFF and a click shows all).
const face = locator => locator.evaluate(element => [getComputedStyle(element).backgroundColor, getComputedStyle(element).color].join(' '))
const allSwitch = layerPanel.getByRole('switch', { name: '전체 레이어' })
const switchOn = async () => (await allSwitch.getAttribute('aria-checked')) === 'true'
const hiddenRows = () => layerPanel.locator('li[data-visible="false"]').count()
check('layer rows use the header bulb icon', (await layerPanel.locator('li button .hm-sw-header__layer-icon--composite').count()) === (await layerPanel.locator('li').count()) && (await layerPanel.locator('li button svg').count()) === 0)
check('all-layers switch ON when all shown, no 전체 켜기 / 끄기 buttons', await switchOn() && (await layerPanel.getByRole('button', { name: /전체 (켜기|끄기)/ }).count()) === 0)
check('all-layers control is the bulb icon, no SWViewerSwitch', (await allSwitch.locator('.hm-sw-header__layer-icon--composite').count()) === 1 && (await layerPanel.locator('.hm-sw-viewer-switch').count()) === 0)
await layerPanel.getByRole('button', { name: 'CR-TEXT 숨기기' }).click()
check('panel content callback', (await log(page)).includes('layer:CR-TEXT=false'))
check('one layer hidden -> switch OFF (partial)', !(await switchOn()) && (await hiddenRows()) === 1)
+1 -1
View File
@@ -225,7 +225,7 @@ import "@hm-design/sw/styles.css"
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(뷰어 모드) → `VIEWER.md`, 예제 `ai/examples/ExampleH-ViewerMode.tsx`
- 재사용: `SWViewerHeader`(`groups` 데이터: `button`·`toggle`·`menu`·`tabs`·`switch`·`custom` 항목, `onCommand`, `trailing`에 `SWViewerStatus`·`SWViewerHeaderButton`. 창 최소화·닫기가 필요한 버전은 `trailing` 맨 끝에 `SWHeaderWindowControls showWindowMove={false}` — VIEWER.md §2.1), Dropdown은 `type: "menu"` 항목의 `entries`(단독·우클릭은 `SWViewerMenu`), 팝업은 `SWViewerPanel` + `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`
- 신규: 뷰어에 없는 패널 콘텐츠 → 소비 프로젝트 컴포넌트를 `SWViewerPanel`의 `children`에, 안쪽은 `SWViewerField` + `SWViewerInput` 등, 외형은 `--hm-sw-viewer-*` 토큰
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기(예외: 위 `trailing` 끝의 `SWHeaderWindowControls` 최소화·닫기), 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 레이어 Popup의 레이어별 표시·숨김을 `SWViewerSwitch`·Checkbox·눈 아이콘으로 만들기(전구 `SWLayerVisibilityIcon` 아이콘 버튼 필수, 전체 레이어는 상단 `SWViewerSwitch` 하나, VIEWER.md §4.1), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기(예외: 위 `trailing` 끝의 `SWHeaderWindowControls` 최소화·닫기), 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 레이어 Popup의 레이어별 표시·숨김을 `SWViewerSwitch`·Checkbox·눈 아이콘으로 만들기(전구 `SWLayerVisibilityIcon` 아이콘 버튼 필수, 전체 레이어도 상단의 같은 전구 버튼 하나, VIEWER.md §4.1), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
+3 -3
View File
@@ -18,10 +18,10 @@
min-width: 0;
margin-inline: auto;
}
/* Wide screens (2026-10-22): 80% of the viewport, i.e. 1536px at 1920px, growing beyond it (vw, so a scrollbar does not
shrink it). Below 1920px the rule above applies. */
/* Wide screens (2026-10-22): 1440px from 1920px up, fixed beyond it (was 80vw); never wider than the page minus the
gutters, so no horizontal scroll. Below 1920px the rule above applies. */
@media (min-width: 1920px) {
.sb-page > .sb-page-inner { width: 80vw; }
.sb-page > .sb-page-inner { width: min(1440px, calc(100% - 2 * var(--sb-page-gutter))); }
}
.sb-page .sb-page-header {
+3 -1
View File
@@ -2,7 +2,9 @@
hm-sw-* classes. Surfaces behind standalone parts read the public --hm-sw-footer-* tokens. */
.sw-footer-story-screen { position: relative; display: flex; flex-direction: column; background: #060606; }
.sw-footer-story-canvas { position: relative; background: var(--sw-story-canvas); }
.sw-footer-story-workspace { display: flex; flex-direction: column; height: 720px; background: #060606; }
/* 2026-10-22: the workspace fits the viewport under the page title (720px at most), so the page itself does not
scroll where there is room; a short window keeps 480px and the page scrolls instead of clipping. */
.sw-footer-story-workspace { display: flex; flex-direction: column; height: clamp(480px, calc(100dvh - 210px), 720px); background: #060606; }
.sw-footer-story-canvas--fill { flex: 1 1 auto; min-height: 0; }
.sw-footer-story-nodes { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 16px; }
.sw-footer-story-node { margin: 0; padding: 12px; border: 1px solid #dedbd2; border-radius: 6px; background: #fff; }
+7 -16
View File
@@ -29,23 +29,15 @@ export default meta
type Story = StoryObj<typeof meta>
/** A program screen bottom: drawing area above the footer (the layout tabs sit on its lower edge). */
function Screen({ width = 1920, canvas = 160, fluid = false, children }: { width?: number | string; canvas?: number; fluid?: boolean; children: ReactNode }) {
// `fluid` (2026-10-22, Responsive More): `width` is a maximum and there is no scroller, so the screen background and
// the footer share the available width, commands that do not fit go to ▲ More, and an open menu is never clipped.
if (fluid) return (
function Screen({ width = 1920, canvas = 160, children }: { width?: number | string; canvas?: number; children: ReactNode }) {
// 2026-10-22: always fluid (no inner scroller). `width` is a maximum, so the screen background and the footer share
// the available width, commands that do not fit go to ▲ More, and an open menu is never clipped.
return (
<div className="sw-footer-story-screen" style={{ width: "100%", maxWidth: width }} data-width={width}>
<div className="sw-footer-story-canvas" style={{ height: canvas }} aria-label="도면 영역 (예시)" />
{children}
</div>
)
return (
<div className="sw-story-scroller">
<div className="sw-footer-story-screen" style={{ width }}>
<div className="sw-footer-story-canvas" style={{ height: canvas }} aria-label="도면 영역 (예시)" />
{children}
</div>
</div>
)
}
function FigmaFooter() {
@@ -66,8 +58,8 @@ function Workspace({ width = "100%" }: { width?: number | string }) {
return (
<>
<Log lines={demo.log} />
<div className="sw-story-scroller">
<div className="sw-footer-story-workspace" style={{ width }}>
{/* 2026-10-22: no inner scroller; `width` is a maximum and the header / footer fit the available width. */}
<div className="sw-footer-story-workspace" style={{ width: "100%", maxWidth: width }}>
<SWHeader documentTitle="Drawing1.grm" logoSrc={placeholderLogo} logoAlt="예제 SW" />
<div className="sw-footer-story-canvas sw-footer-story-canvas--fill" aria-label="도면 영역 (예시)" />
<SWFooter categories={REFERENCE_CATEGORIES} categoryId={demo.categoryId} onCategoryChange={demo.onCategoryChange}
@@ -78,7 +70,6 @@ function Workspace({ width = "100%" }: { width?: number | string }) {
auxAll={{ onClick: demo.onSide("auxAll") }} commandAll={{ onClick: () => { setCommandOpen(open => !open); demo.onSide("commandAll")() }, pressed: commandOpen }}
layoutTabs={<SWFooterLayoutTabs layouts={demo.layouts} value={demo.space} onValueChange={demo.onSpaceChange}
onAdd={demo.onLayoutAdd} onRemove={demo.onLayoutRemove} onRename={demo.onLayoutRename} />} />
</div>
</div>
</>
)
@@ -394,7 +385,7 @@ export const Responsive: Story = {
{[1920, 1440, 1280, 950, 860, 760].map(width => (
<div key={width} className={`sw-footer-story-width${width === 1920 ? " sw-story-responsive-wide" : ""}`}>
<StorySection title={`${width}px`}>
<Screen width={width} canvas={90} fluid><ReferenceFooter /></Screen>
<Screen width={width} canvas={90}><ReferenceFooter /></Screen>
</StorySection>
</div>
))}
+8 -5
View File
@@ -109,17 +109,20 @@ export function LayerPanel({ demo, ...placement }: { demo: ViewerDemo } & PanelP
const [query, setQuery] = useState("")
const filtered = LAYERS.filter(layer => layer.name.toLowerCase().includes(query.trim().toLowerCase()))
const set = (names: string[]) => demo.setHidden(new Set(names))
// 2026-10-22: one switch for all layers. ON only when every layer is shown; a partial state reads OFF, so a click turns all on.
// 2026-10-22: one bulb for all layers (was a switch). ON only when every layer is shown; a partial state reads OFF, so a click turns all on.
const allVisible = demo.hidden.size === 0
const setAll = (next: boolean) => { set(next ? [] : LAYERS.map(layer => layer.name)); demo.record(`layer:all=${next}`) }
return (
<SWViewerPanel title={`레이어 (${LAYERS.length}개)`} open={demo.panels.layers} onClose={() => demo.toggle("layers", false)} {...placement}>
<SWViewerSection surface="plain" aria-label="레이어 목록">
{/* 2026-10-22: search + 전체 레이어 stay fixed; only the layer list scrolls (.sw-viewer-story-layers). */}
<SWViewerSection surface="plain" aria-label="레이어 목록" className="sw-viewer-story-layers">
<div className="sw-viewer-story-toolbar">
<SWViewerInput type="search" placeholder="레이어 검색" aria-label="레이어 검색" value={query} onChange={event => setQuery(event.currentTarget.value)} />
{/* 2026-10-22: the section title (.hm-sw-viewer-section__title) names the switch row; the switch stays on that line. */}
{/* 2026-10-22: the section title (.hm-sw-viewer-section__title) names the row; the bulb (same button as the rows)
stays on that line and keeps the former switch semantics (role="switch", name 전체 레이어). */}
<SWViewerSection surface="plain" title="전체 레이어" className="sw-viewer-story-all">
<SWViewerSwitch aria-label="전체 레이어" checked={allVisible}
onCheckedChange={next => { set(next ? [] : LAYERS.map(layer => layer.name)); demo.record(`layer:all=${next}`) }} />
<button type="button" role="switch" aria-checked={allVisible} aria-label="전체 레이어" className="sw-viewer-story-bulb"
title={allVisible ? "전체 숨기기" : "전체 표시하기"} onClick={() => setAll(!allVisible)}><SWLayerVisibilityIcon on={allVisible} /></button>
</SWViewerSection>
</div>
{filtered.length === 0 ? <SWViewerHint>검색 결과가 없습니다 ("{query}")</SWViewerHint> : (
+14 -2
View File
@@ -8,11 +8,23 @@
background: var(--sw-story-canvas);
}
.sw-viewer-story-room { padding-bottom: 320px; }
/* Search, then the all-layers switch row (SWViewerField + SWViewerSwitch, 2026-10-22). */
/* Search, then the all-layers bulb row (section title + SWLayerVisibilityIcon button, 2026-10-22). */
.sw-viewer-story-toolbar { display: flex; flex-wrap: wrap; gap: 12px 4px; }
.sw-viewer-story-toolbar > * { flex: 1 0 100%; }
/* 전체 레이어: the plain SWViewerSection laid out as one row — its title on the left, the switch on the right. */
/* 전체 레이어: the plain SWViewerSection laid out as one row — its title on the left, the bulb on the right. */
.sw-viewer-story-toolbar > .sw-viewer-story-all { flex-direction: row; align-items: center; justify-content: space-between; min-height: 20px; }
/* Its bulb lines up with the row bulbs: the row padding (6px) plus the same scrollbar gutter the list reserves. The
gutter needs overflow, so the focus ring is drawn inside the 24px button. */
.sw-viewer-story-layers .sw-viewer-story-all { padding-right: 6px; overflow-y: hidden; scrollbar-gutter: stable; }
/* 2026-10-22 layer image: one light line under the 전체 레이어 row (none between search and that row), the SW
property table tone (#d2c8b4) rather than the darker inner border. */
.sw-viewer-story-layers .sw-viewer-story-all { padding-bottom: 8px; border-bottom: 1px solid var(--hm-sw-property-table); }
.sw-viewer-story-all .sw-viewer-story-bulb:focus-visible { outline-offset: -2px; }
/* Layer panel (2026-10-22): the section fills the panel body, search + 전체 레이어 stay put and only the list scrolls.
No divider between the fixed rows and the list; the list keeps the panel's scrollbar style. */
.sw-viewer-story-layers { flex: 1 1 auto; min-height: 0; }
.sw-viewer-story-layers > .sw-viewer-story-toolbar { flex-shrink: 0; }
.sw-viewer-story-layers > .sw-viewer-story-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
.sw-viewer-story-list { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
.sw-viewer-story-list li { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 3px; }
.sw-viewer-story-list li:hover { background: rgba(154, 146, 132, 0.25); }