feat(sw): viewer follow-up — SVG icon outline, shared SW popup frame, story grid, green layer buttons

- A: SWViewerIcon draws the SWHeader black outline as SVG stroke on a copy behind the icon (no CSS filter)
- B: SWViewerPanel renders the common SWPopup frame (same DOM / CSS) and adds drag, corner resize,
  collapse, bounds and modal; SWPopup gains optional titleActions / footer; title art spans any width
- C: shared Story canvas grid (--sw-story-canvas) behind the SW Header work area and dropdown room
- D: SWViewerButton tone="green" for 전체 켜기 / 전체 끄기: default kept, hover = primary (확인) hover
  token, pressed = primary face
- AI guide, verification scripts (76 checks, element regression) and evidence updated
This commit is contained in:
shkim4 committed 2026-10-19 16:34:50 +09:00
1 parent 7f42923ccf
commit 68fbcf6e15
45 files changed
+1463 -377

No files matched your search

+13
View File
@@ -110,3 +110,16 @@
## E. 최종 판정
**PARTIAL**: 패키지 컴포넌트 구현, Storybook, AI 지침, 주요 UI·인터랙션 검증은 끝났습니다. PASS가 아닌 이유는 요청 원문의 “SW 프로그램 뷰어 모드 통합”이 이번 범위에서 제외되어(사용자 결정) 진행하지 않았기 때문입니다. Commit·Push는 승인 대기입니다.
## F. 후속 디자인 수정 (Push `7f42923` 이후, 미커밋)
| 항목 | 적용 | 검증 |
|---|---|---|
| A. 아이콘 외곽선 filter → SVG stroke | 신규 `SWViewerIcon`(공개). 아이콘 뒤 복사본의 도형을 검정 Stroke(원래 두께 + 화면 기준 양쪽 1px, `--hm-sw-viewer-icon-outline-width`), 채운 도형은 검정 채움. 위 아이콘은 그대로. CSS `filter`와 glow 제거 | 계산값: filter `none`, 복사본 stroke 검정 5 unit / 원본 2 unit(24 unit 아이콘을 16px로 그린 경우 1px = 1.5 unit). 3배 캡처에서 모양·선 두께 유지 |
| B. Viewer Popup = SW Popup | `SWViewerPanel`이 공통 `SWPopup`을 렌더링(같은 DOM·CSS). `SWPopup`에 선택 Props `titleActions`, `footer` 추가(기본 동작 동일). 제목 아이콘 Prop 제거, 기본 폭 314. 314px가 아닌 폭은 제목 아트를 오른쪽 정렬 + 왼쪽 평면 확장(이음매 없음). 패널 콘텐츠 토큰을 SW 속성창 토큰(`--hm-sw-property-*`)에 연결 | SW/Popup·Viewer 패널 계산값 동일(1px #A67D03, r4, inset 1px 검정, #E4DBC9, 제목 막대 30px, 제목 #FFC600 14px 700 + 1px 외곽선). 드래그·크기 조절·접기·Escape·모달·앞으로 가져오기 PASS |
| C. SW Header 메뉴 배경 그리드 | Story 공용 변수 `--sw-story-canvas`(Viewer 캔버스와 같은 40px 칸, 1px #266651 18%, #0A0A0B)를 SW Header Overview 작업 영역과 Dropdown 공간에 적용. SW Header 컴포넌트 자체는 변경 없음 | 캡처 확인. 회귀: 이전 배경을 테스트 CSS로 되돌리면 Header·Popup 요소 39/39 픽셀 동일, 실제 차이는 배경 투과 픽셀뿐 |
| D. 레이어 전체 켜기 / 끄기 | `SWViewerButton tone="green"`: 기본 유지, Hover = 확인(primary) 버튼 Hover와 같은 토큰 `--hm-sw-viewer-hover`, 누르는 동안 = primary 면 `--hm-sw-viewer-panel-primary`. 새 HEX 없음. 다른 버튼 영향 없음 | 기본 → Hover → Active → 놓은 뒤 Hover → 이탈 후 기본 계산값 PASS, 확인 버튼 Hover와 정확히 동일 |
- 해석(C): “Viewer Header의 검정 그리드”는 Header 컴포넌트가 아니라 Story 도면 영역 배경이라 Story 레벨 공용 변수로 적용했습니다.
- 높이(추가 요청): 패키지에 뷰어 영역 고정 높이가 없음을 확인했습니다(Header 52px 한 줄, 팝업 최대 90vh). Storybook 프레임 높이는 Story 전용 값이고, 프로그램 레이아웃 규칙은 `ai/VIEWER.md` §7에 적었습니다. 실제 테스트 프로젝트(`HMDesignSwTest-viewer`)는 다른 세션이 작업 중이라 이 세션에서는 수정하지 않았습니다. 읽기 확인 결과, 화면 높이는 `h-full` 세로 flex에 도면 영역 `flex-1 min-h-0`이고, 렌더러 크기 조정은 window `resize`에만 연결되어 있습니다(컨테이너 크기 변화에는 미대응).
- 검증: `viewer.browser.mjs` 76/76, 요소 회귀 39/39(`--neutral-canvas`), `npm run typecheck` 0 오류, `build-storybook` 성공, `ai-docs.check` 25/25.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,636 @@
{
"base": "http://127.0.0.1:6024",
"next": "http://127.0.0.1:6034",
"results": [
{
"id": "sw-header--overview",
"width": 1920,
"elements": "1 / 1",
"elementDiffPixels": [
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--overview",
"width": 1180,
"elements": "1 / 1",
"elementDiffPixels": [
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--overview",
"width": 768,
"elements": "1 / 1",
"elementDiffPixels": [
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--interactive-states",
"width": 1920,
"elements": "16 / 16",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--interactive-states",
"width": 1180,
"elements": "16 / 16",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--interactive-states",
"width": 768,
"elements": "16 / 16",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--logo-slot",
"width": 1920,
"elements": "4 / 4",
"elementDiffPixels": [
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--logo-slot",
"width": 1180,
"elements": "4 / 4",
"elementDiffPixels": [
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--logo-slot",
"width": 768,
"elements": "4 / 4",
"elementDiffPixels": [
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--document-dropdown",
"width": 1920,
"elements": "18 / 18",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--document-dropdown",
"width": 1180,
"elements": "18 / 18",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--document-dropdown",
"width": 768,
"elements": "18 / 18",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--responsive-more",
"width": 1920,
"elements": "19 / 19",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--responsive-more",
"width": 1180,
"elements": "19 / 19",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--responsive-more",
"width": 768,
"elements": "19 / 19",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--k-guide",
"width": 1920,
"elements": "1 / 1",
"elementDiffPixels": [
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--k-guide",
"width": 1180,
"elements": "1 / 1",
"elementDiffPixels": [
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--k-guide",
"width": 768,
"elements": "1 / 1",
"elementDiffPixels": [
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--property-panel-compare",
"width": 1920,
"elements": "4 / 4",
"elementDiffPixels": [
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--property-panel-compare",
"width": 1180,
"elements": "4 / 4",
"elementDiffPixels": [
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--property-panel-compare",
"width": 768,
"elements": "4 / 4",
"elementDiffPixels": [
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--composed-controls",
"width": 1920,
"elements": "0 / 0",
"elementDiffPixels": [],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--composed-controls",
"width": 1180,
"elements": "0 / 0",
"elementDiffPixels": [],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--composed-controls",
"width": 768,
"elements": "0 / 0",
"elementDiffPixels": [],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--document-open",
"width": 1920,
"elements": "23 / 23",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--document-open",
"width": 1180,
"elements": "23 / 23",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--document-open",
"width": 768,
"elements": "23 / 23",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--hidden-sub-controls",
"width": 1920,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--hidden-sub-controls",
"width": 1180,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--hidden-sub-controls",
"width": 768,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--detail-buttons",
"width": 1920,
"elements": "0 / 0",
"elementDiffPixels": [],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--detail-buttons",
"width": 1180,
"elements": "0 / 0",
"elementDiffPixels": [],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-header--detail-buttons",
"width": 768,
"elements": "0 / 0",
"elementDiffPixels": [],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-popup--custom-content",
"width": 1920,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-popup--custom-content",
"width": 1180,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-popup--custom-content",
"width": 768,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-popup--multiple-popups",
"width": 1920,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-popup--multiple-popups",
"width": 1180,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-popup--multiple-popups",
"width": 768,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
}
]
}
+509 -110
View File
@@ -3,234 +3,633 @@
"next": "http://127.0.0.1:6034",
"results": [
{
"id": "overview",
"id": "sw-header--overview",
"width": 1920,
"size": "1920x1000 / 1920x1000",
"diffPixels": 0,
"result": "PASS"
"elements": "1 / 1",
"elementDiffPixels": [
667
],
"fullPageDiffPixels": 716941,
"result": "FAIL"
},
{
"id": "overview",
"id": "sw-header--overview",
"width": 1180,
"size": "1180x1000 / 1180x1000",
"diffPixels": 0,
"elements": "1 / 1",
"elementDiffPixels": [
0
],
"fullPageDiffPixels": 606746,
"result": "PASS"
},
{
"id": "overview",
"id": "sw-header--overview",
"width": 768,
"size": "768x1000 / 768x1000",
"diffPixels": 0,
"elements": "1 / 1",
"elementDiffPixels": [
0
],
"fullPageDiffPixels": 237853,
"result": "PASS"
},
{
"id": "interactive-states",
"id": "sw-header--interactive-states",
"width": 1920,
"size": "1920x2517 / 1920x2517",
"diffPixels": 0,
"elements": "16 / 16",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "interactive-states",
"id": "sw-header--interactive-states",
"width": 1180,
"size": "1180x2527 / 1180x2527",
"diffPixels": 0,
"elements": "16 / 16",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "interactive-states",
"id": "sw-header--interactive-states",
"width": 768,
"size": "768x2623 / 768x2623",
"diffPixels": 0,
"elements": "16 / 16",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "logo-slot",
"id": "sw-header--logo-slot",
"width": 1920,
"size": "1920x1000 / 1920x1000",
"diffPixels": 0,
"elements": "4 / 4",
"elementDiffPixels": [
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "logo-slot",
"id": "sw-header--logo-slot",
"width": 1180,
"size": "1180x1000 / 1180x1000",
"diffPixels": 0,
"elements": "4 / 4",
"elementDiffPixels": [
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "logo-slot",
"id": "sw-header--logo-slot",
"width": 768,
"size": "768x1000 / 768x1000",
"diffPixels": 0,
"elements": "4 / 4",
"elementDiffPixels": [
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "document-dropdown",
"id": "sw-header--document-dropdown",
"width": 1920,
"size": "1920x9112 / 1920x9112",
"diffPixels": 0,
"elements": "18 / 18",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 6111124,
"result": "PASS"
},
{
"id": "document-dropdown",
"id": "sw-header--document-dropdown",
"width": 1180,
"size": "1180x9122 / 1180x9122",
"diffPixels": 0,
"result": "PASS"
"elements": "18 / 18",
"elementDiffPixels": [
793,
0,
0,
0,
0,
0,
1109,
793,
793,
793,
793,
793,
793,
0,
0,
0,
0,
48
],
"fullPageDiffPixels": 5602299,
"result": "FAIL"
},
{
"id": "document-dropdown",
"id": "sw-header--document-dropdown",
"width": 768,
"size": "768x9298 / 768x9298",
"diffPixels": 0,
"result": "PASS"
"elements": "18 / 18",
"elementDiffPixels": [
405,
0,
0,
0,
0,
0,
721,
405,
405,
405,
405,
405,
405,
0,
0,
0,
0,
48
],
"fullPageDiffPixels": 3410951,
"result": "FAIL"
},
{
"id": "responsive-more",
"id": "sw-header--responsive-more",
"width": 1920,
"size": "1920x10042 / 1920x10042",
"diffPixels": 0,
"elements": "19 / 19",
"elementDiffPixels": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"fullPageDiffPixels": 8338418,
"result": "PASS"
},
{
"id": "responsive-more",
"id": "sw-header--responsive-more",
"width": 1180,
"size": "1180x10052 / 1180x10052",
"diffPixels": 0,
"result": "PASS"
"elements": "19 / 19",
"elementDiffPixels": [
0,
818,
50,
0,
0,
0,
0,
0,
0,
47,
48,
48,
48,
48,
47,
48,
47,
48,
48
],
"fullPageDiffPixels": 7674707,
"result": "FAIL"
},
{
"id": "responsive-more",
"id": "sw-header--responsive-more",
"width": 768,
"size": "768x10048 / 768x10048",
"diffPixels": 0,
"result": "PASS"
"elements": "19 / 19",
"elementDiffPixels": [
0,
721,
50,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
47,
48,
47,
48,
48
],
"fullPageDiffPixels": 4880711,
"result": "FAIL"
},
{
"id": "k-guide",
"id": "sw-header--k-guide",
"width": 1920,
"size": "1920x1037 / 1920x1037",
"diffPixels": 0,
"elements": "1 / 1",
"elementDiffPixels": [
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "k-guide",
"id": "sw-header--k-guide",
"width": 1180,
"size": "1180x1028 / 1180x1028",
"diffPixels": 0,
"elements": "1 / 1",
"elementDiffPixels": [
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "k-guide",
"id": "sw-header--k-guide",
"width": 768,
"size": "768x1023 / 768x1023",
"diffPixels": 0,
"elements": "1 / 1",
"elementDiffPixels": [
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "property-panel-compare",
"id": "sw-header--property-panel-compare",
"width": 1920,
"size": "1920x1000 / 1920x1000",
"diffPixels": 0,
"elements": "4 / 4",
"elementDiffPixels": [
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "property-panel-compare",
"id": "sw-header--property-panel-compare",
"width": 1180,
"size": "1180x1000 / 1180x1000",
"diffPixels": 0,
"elements": "4 / 4",
"elementDiffPixels": [
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "property-panel-compare",
"id": "sw-header--property-panel-compare",
"width": 768,
"size": "768x1000 / 768x1000",
"diffPixels": 0,
"elements": "4 / 4",
"elementDiffPixels": [
0,
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "composed-controls",
"id": "sw-header--composed-controls",
"width": 1920,
"size": "1920x1000 / 1920x1000",
"diffPixels": 0,
"elements": "0 / 0",
"elementDiffPixels": [],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "composed-controls",
"id": "sw-header--composed-controls",
"width": 1180,
"size": "1180x1000 / 1180x1000",
"diffPixels": 0,
"elements": "0 / 0",
"elementDiffPixels": [],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "composed-controls",
"id": "sw-header--composed-controls",
"width": 768,
"size": "768x1000 / 768x1000",
"diffPixels": 0,
"elements": "0 / 0",
"elementDiffPixels": [],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "document-open",
"id": "sw-header--document-open",
"width": 1920,
"size": "1920x10771 / 1920x10771",
"diffPixels": 0,
"result": "PASS"
"elements": "23 / 23",
"elementDiffPixels": [
0,
0,
0,
0,
1199,
1198,
1180,
1078,
899,
768,
640,
520,
388,
0,
1200,
1200,
1180,
1080,
900,
766,
640,
520,
390
],
"fullPageDiffPixels": 10359147,
"result": "FAIL"
},
{
"id": "document-open",
"id": "sw-header--document-open",
"width": 1180,
"size": "1180x10761 / 1180x10761",
"diffPixels": 0,
"result": "PASS"
"elements": "23 / 23",
"elementDiffPixels": [
0,
0,
0,
0,
1108,
1108,
1110,
1127,
948,
817,
689,
569,
437,
0,
1110,
1110,
1110,
1129,
949,
815,
689,
569,
439
],
"fullPageDiffPixels": 9548042,
"result": "FAIL"
},
{
"id": "document-open",
"id": "sw-header--document-open",
"width": 768,
"size": "768x10797 / 768x10797",
"diffPixels": 0,
"result": "PASS"
"elements": "23 / 23",
"elementDiffPixels": [
0,
0,
0,
0,
721,
721,
722,
722,
721,
722,
689,
569,
437,
0,
722,
722,
722,
722,
722,
720,
689,
569,
439
],
"fullPageDiffPixels": 6118190,
"result": "FAIL"
},
{
"id": "hidden-sub-controls",
"id": "sw-header--hidden-sub-controls",
"width": 1920,
"size": "1920x1710 / 1920x1710",
"diffPixels": 0,
"result": "PASS"
"elements": "3 / 3",
"elementDiffPixels": [
0,
1199,
0
],
"fullPageDiffPixels": 1178934,
"result": "FAIL"
},
{
"id": "hidden-sub-controls",
"id": "sw-header--hidden-sub-controls",
"width": 1180,
"size": "1180x1700 / 1180x1700",
"diffPixels": 0,
"result": "PASS"
"elements": "3 / 3",
"elementDiffPixels": [
0,
1110,
0
],
"fullPageDiffPixels": 1084137,
"result": "FAIL"
},
{
"id": "hidden-sub-controls",
"id": "sw-header--hidden-sub-controls",
"width": 768,
"size": "768x1676 / 768x1676",
"diffPixels": 0,
"result": "PASS"
"elements": "3 / 3",
"elementDiffPixels": [
0,
722,
0
],
"fullPageDiffPixels": 676260,
"result": "FAIL"
},
{
"id": "detail-buttons",
"id": "sw-header--detail-buttons",
"width": 1920,
"size": "1920x1000 / 1920x1000",
"diffPixels": 0,
"elements": "0 / 0",
"elementDiffPixels": [],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "detail-buttons",
"id": "sw-header--detail-buttons",
"width": 1180,
"size": "1180x1000 / 1180x1000",
"diffPixels": 0,
"elements": "0 / 0",
"elementDiffPixels": [],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "detail-buttons",
"id": "sw-header--detail-buttons",
"width": 768,
"size": "768x1000 / 768x1000",
"diffPixels": 0,
"elements": "0 / 0",
"elementDiffPixels": [],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-popup--custom-content",
"width": 1920,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-popup--custom-content",
"width": 1180,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-popup--custom-content",
"width": 768,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-popup--multiple-popups",
"width": 1920,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-popup--multiple-popups",
"width": 1180,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
},
{
"id": "sw-popup--multiple-popups",
"width": 768,
"elements": "3 / 3",
"elementDiffPixels": [
0,
0,
0
],
"fullPageDiffPixels": 0,
"result": "PASS"
}
]
+15 -5
View File
@@ -374,7 +374,7 @@
{
"name": "corner resize changes size",
"result": "PASS",
"detail": "320x420 → 263x323"
"detail": "314x420 → 257x323"
},
{
"name": "drag moves panel",
@@ -396,13 +396,18 @@
"result": "PASS",
"detail": ""
},
{
"name": "전체 켜기: default kept, green hover / active, back after release",
"result": "PASS",
"detail": "rgb(255, 255, 255) rgb(18, 51, 40) | rgb(38, 102, 81) rgb(255, 255, 255) | rgb(18, 51, 40) rgb(255, 255, 255) | rgb(38, 102, 81) rgb(255, 255, 255) | rgb(255, 255, 255) rgb(18, 51, 40)"
},
{
"name": "panel content callback",
"result": "PASS",
"detail": ""
},
{
"name": "close hover #E40000 / white X",
"name": "close hover = SWPopup hover asset",
"result": "PASS",
"detail": ""
},
@@ -414,7 +419,12 @@
{
"name": "last touched panel on top",
"result": "PASS",
"detail": "layer z 37, options z 36"
"detail": "layer z 47, options z 46"
},
{
"name": "전체 켜기 hover = 라이선스 확인 hover (same token)",
"result": "PASS",
"detail": "rgb(38, 102, 81) rgb(255, 255, 255) / rgb(38, 102, 81) rgb(255, 255, 255)"
},
{
"name": "modal popup opens with backdrop",
@@ -582,9 +592,9 @@
"detail": "rgb(0, 0, 0) 1px 0px 0px, rgb(0, 0, 0) 0.92px 0.38px 0px, rg"
},
{
"name": "icon outline: 4 x 1px black drop-shadows + #061A14 glow",
"name": "icon outline: SVG stroke copy, no CSS filter",
"result": "PASS",
"detail": "drop-shadow(rgb(0, 0, 0) 1px 0px 0px) drop-shadow(rgb(0, 0, 0) -1px 0px 0px) drop-shadow(r"
"detail": "{\"filter\":\"none\",\"copyStroke\":\"rgb(0, 0, 0)\",\"copyWidth\":5,\"glyphStroke\":\"rgb(255, 255, 255)\",\"glyphWidth\":2}"
},
{
"name": "fill / size / weight / spacing kept",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+7 -6
View File
@@ -39,7 +39,7 @@
- **반응형(참고 뷰어와 동일):** 항목별 `collapseBelow`로 More(⋯, 베이지 260px Dropdown)로 이동합니다. 참고 뷰어 기준값: 샘플·도면 공간·2.5D·어두운 배경 `1180`, 패널 토글 `900`, 파일 열기·전체 보기 `iconOnlyBelow: 560`. 1180px 미만은 간격 2px, 560px 미만은 상태 글자 숨김(CSS container). 가로 스크롤은 만들지 않습니다.
- More는 그룹/항목 단위 구획으로 자동 구성됩니다: menu·tabs는 자기 구획(`moreLabel`), button·toggle·switch는 그룹 구획(`group.moreLabel ?? group.label`). toggle·switch는 체크 항목, tabs는 라디오 항목이 됩니다.
- **상태 표현:** Hover = #266651 면, Active·Pressed·열림·선택 탭 = 글자·아이콘 #FFC600(선택 박스 없음), Disabled = 60% 불투명, Focus = 2px #FFC600 외곽선.
- **글자·아이콘 외곽선(SWHeader와 동일):** 14px 글자 `--hm-sw-header-text-outline`(검정 1.3px), 11px 캡션·개수·상태 `--hm-sw-header-label-outline`(검정 1px), 아이콘 `--hm-sw-viewer-icon-outline`(SWHeader 아이콘 자산의 1px 검정 바깥 Stroke + #061A14 glow를 `drop-shadow`로 재현, 아이콘 윤곽을 따름). 색·크기·굵기·간격은 그대로이고 Hover/Active는 채움 색만 바뀝니다. Dropdown·패널 안에는 외곽선이 없습니다(SWHeader More 메뉴와 같음). 프로그램 아이콘은 `currentColor`로 그리면 상태 색과 외곽선이 자동 적용됩니다.
- **글자·아이콘 외곽선(SWHeader와 동일):** 14px 글자 `--hm-sw-header-text-outline`(검정 1.3px), 11px 캡션·개수·상태 `--hm-sw-header-label-outline`(검정 1px), 아이콘 `SWViewerIcon`(SWHeader 아이콘 자산의 1px 검정 바깥 Stroke를 **SVG stroke**로 재현: 아이콘 뒤 복사본의 모든 도형을 검정, 원래 선 두께 + 화면 기준 양쪽 1px(`--hm-sw-viewer-icon-outline-width`)로 그림. 위 아이콘은 그대로라 색·모양·선 두께 유지. CSS `filter` 미사용, glow는 생략). Header 항목·`SWViewerHeaderButton`의 `icon`은 자동 적용, `custom` / `trailing`의 직접 그린 아이콘은 `<SWViewerIcon>`으로 감쌉니다. 색·크기·굵기·간격은 그대로이고 Hover/Active는 채움 색만 바뀝니다. Dropdown·패널 안에는 외곽선이 없습니다(SWHeader More 메뉴와 같음). 프로그램 아이콘은 `currentColor`로 그리면 상태 색과 외곽선이 자동 적용됩니다.
## 3. `SWViewerMenu` / `SWViewerMenuEntry`
@@ -50,15 +50,15 @@
## 4. `SWViewerPanel` — 팝업 프레임 + 자유 콘텐츠
외형(베이지 #E0DACE 본문, 2px #897445 테두리, #221D13 제목 막대, #FFC600 제목, 접기, 곡선 녹색 닫기 → Hover #E40000)과 공통 동작은 패키지가, 내용은 `children`·`footer`가 담당합니다.
프레임은 **공통 SW 팝업 `SWPopup`과 같은 컴포넌트·DOM**입니다(30px 제목 막대·Figma 제목 아트, #FFC600 700 제목 + 검정 1px 외곽선, SW 닫기 버튼, 1px #A67D03 테두리, 반경 4, #E4DBC9 면). 뷰어는 여기에 드래그·모서리 크기 조절·접기·경계 유지·앞으로 가져오기·모달을 더합니다. 314px가 아닌 폭에서도 제목 아트 곡선은 닫기 버튼에 붙어 있고 왼쪽 평면이 늘어납니다. 내용은 `children`·`footer`가 담당합니다. 일반 SW 팝업은 `SWPopup`(`titleActions?`, `footer?` 추가)을 그대로 씁니다.
| Props | 설명 |
|---|---|
| `title`, `icon?`, `open`, `onClose`, `closeLabel?` | 필수는 `title`·`open`·`onClose` |
| `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"` |
| `headerActions?` | 제목 막대 추가 버튼: `SWViewerButton variant="ghost"` (기본값 복원, 선택 확대 등) |
| `width?`(320), `height?`(420, `"auto"` 가능), `minWidth?`(250), `minHeight?`(140) | 메뉴별 크기. 최대 90vw × 90vh |
| `footer?` | 하단 버튼 영역(본문 스크롤과 무관하게 고정). `SWViewerButton variant="outline" \| "primary"`. `tone="green"` = 기본은 그대로, Hover는 primary(확인) 버튼 Hover와 같은 토큰 `--hm-sw-viewer-hover`, 누르는 동안 primary 면 `--hm-sw-viewer-panel-primary` (예: 레이어 전체 켜기 / 전체 끄기) |
| `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 아래) |
| `strategy?` | `fixed`(기본, 화면) / `absolute`(가장 가까운 positioned 조상 안 — 미리보기·임베드) |
| `draggable?`, `resizable?`, `collapsible?`, `defaultCollapsed?`, `collapsed?`, `onCollapsedChange?`, `onPositionChange?`, `onSizeChange?` | 제목 막대 드래그, 오른쪽 아래 모서리 크기 조절, 접기(버튼·제목 더블클릭). 이동·크기는 경계 10px 안 |
@@ -101,4 +101,5 @@ const groups: SWViewerHeaderGroup[] = [
## 7. 디자인 변경 시 주의
- 크기(52px Header, 34px 버튼, 40px 토글·탭·스위치 칸, 41~42px field 면, 72px 로고 면)와 색은 참고 뷰어 실측값입니다. 파일명·객체 수정·속성 면 배경은 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 닫기 추가, 로고 기본 표시 없음(사용자 결정).
@@ -60,13 +60,13 @@ export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => vo
<SWViewerHeaderButton iconOnly icon={icons.info} label="정보" onClick={() => setAbout(true)} />
</>} />
<SWViewerPanel title="레이어" icon={icons.layers} open={panels.layers} onClose={() => setPanels({ ...panels, layers: false })}>
<SWViewerPanel title="레이어" open={panels.layers} onClose={() => setPanels({ ...panels, layers: false })}>
<SWViewerSection surface="plain" aria-label="레이어 목록">
<SWViewerHint>프로그램의 레이어 목록을 여기에 둡니다.</SWViewerHint>
</SWViewerSection>
</SWViewerPanel>
<SWViewerPanel title="옵션" icon={icons.options} open={panels.options} onClose={() => setPanels({ ...panels, options: false })} width={340} height="auto" inset={{ side: 356 }}
<SWViewerPanel title="옵션" open={panels.options} onClose={() => setPanels({ ...panels, options: false })} width={340} height="auto" inset={{ side: 356 }}
footer={<SWViewerButton variant="primary" onClick={() => { onEvent?.("options:apply"); setPanels({ ...panels, options: false }) }}>적용</SWViewerButton>}>
<SWViewerSection title="표시">
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={grid} onCheckedChange={setGrid} />}</SWViewerField>
@@ -17,6 +17,10 @@ export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "titl
autoFocus?: boolean
/** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */
anchorRef?: RefObject<HTMLElement | null>
/** Extra title-bar buttons, before the close button (e.g. collapse, reset). Default none. */
titleActions?: ReactNode
/** Area after the scrolling body (e.g. buttons). Stays visible while the body scrolls. Default none. */
footer?: ReactNode
}
/**
@@ -24,8 +28,8 @@ export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "titl
* content up to the viewport (the body scrolls). Non-modal (no dim, no portal, no focus trap); the host decides
* when it is shown and where (`style`, or `anchorRef`). Wrap it in <SWTheme> outside SWHeader.
*/
export function SWPopup({ closeLabel = "닫기", ...props }: SWPopupProps) {
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" {...props} />
export function SWPopup({ closeLabel = "닫기", footer, ...props }: SWPopupProps) {
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" after={footer} {...props} />
}
export interface SWPopupSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
@@ -1,4 +1,5 @@
import { forwardRef, useId, type ButtonHTMLAttributes, type HTMLAttributes, type InputHTMLAttributes, type ReactNode } from "react"
import { SWViewerIcon } from "./SWViewerIcon"
import "../../theme/sw-viewer.css"
const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(" ")
@@ -47,7 +48,7 @@ const StatusIcon = ({ state }: { state: SWViewerStatusState }) => state === "idl
export function SWViewerStatus({ state, label, detail, className, ...rest }: SWViewerStatusProps) {
return (
<div role="status" className={cx("hm-sw-viewer-status", className)} data-state={state} title={detail} {...rest}>
<StatusIcon state={state} />
{state !== "idle" && <span className="hm-sw-viewer-status__icon"><SWViewerIcon><StatusIcon state={state} /></SWViewerIcon></span>}
<span className="hm-sw-viewer-status__label">{label ?? statusLabel[state]}</span>
{detail && <span className="hm-sw-viewer-sr-only">: {detail}</span>}
</div>
@@ -60,14 +61,16 @@ export interface SWViewerButtonProps extends ButtonHTMLAttributes<HTMLButtonElem
* `primary`: the same size filled with #123328 (footer confirm). `ghost`: icon button of the panel title bar.
*/
variant?: "outline" | "primary" | "ghost"
/** `green`: the default look stays; hover and press turn the face HM green with white text. Default none. */
tone?: "green"
icon?: ReactNode
}
export const SWViewerButton = forwardRef<HTMLButtonElement, SWViewerButtonProps>(function SWViewerButton(
{ variant = "outline", icon, children, className, type = "button", ...rest }, ref,
{ variant = "outline", tone, icon, children, className, type = "button", ...rest }, ref,
) {
return (
<button ref={ref} type={type} className={cx("hm-sw-viewer-button", className)} data-variant={variant} {...rest}>
<button ref={ref} type={type} className={cx("hm-sw-viewer-button", className)} data-variant={variant} data-tone={tone} {...rest}>
{icon && <span className="hm-sw-viewer-button__icon" aria-hidden="true">{icon}</span>}
{children}
</button>
@@ -1,6 +1,7 @@
import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import { SWViewerMenu } from "./SWViewerMenu"
import { SWViewerSwitch } from "./SWViewerControls"
import { SWViewerIcon } from "./SWViewerIcon"
import type { SWViewerCommand, SWViewerHeaderGroup, SWViewerHeaderItem, SWViewerMenuEntry } from "./types"
import "../../theme/sw-viewer.css"
@@ -97,9 +98,9 @@ function MenuButton({ item, iconOnly, onCommand }: { item: Extract<SWViewerHeade
aria-haspopup="menu" aria-expanded={open} aria-controls={open ? menuId : undefined} aria-label={iconOnly ? item.label : undefined} title={item.title} disabled={item.disabled}
onClick={() => (open ? setOpen(false) : show("menu"))}
onKeyDown={event => { if (event.key === "ArrowDown" || ((event.key === "Enter" || event.key === " ") && !open)) { event.preventDefault(); show("first") } }}>
{item.icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true">{item.icon}</span>}
{item.icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true"><SWViewerIcon>{item.icon}</SWViewerIcon></span>}
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</span>}
<span className="hm-sw-viewer-header__chevron"><ChevronDown /></span>
<span className="hm-sw-viewer-header__chevron"><SWViewerIcon><ChevronDown /></SWViewerIcon></span>
</button>
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} label={item.label} initialFocus={focus}
onEntrySelect={entry => onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} />
@@ -190,7 +191,7 @@ export const SWViewerHeaderButton = forwardRef<HTMLButtonElement, SWViewerHeader
return (
<button ref={ref} type={type} className={`hm-sw-viewer-header__button${className ? ` ${className}` : ""}`} data-kind={kind} data-icon-only={iconOnly === true ? "always" : iconOnly ? "" : undefined}
data-state={pressed === undefined ? undefined : pressed ? "on" : "off"} aria-pressed={pressed} aria-label={iconOnly ? label : undefined} {...rest}>
{icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true">{icon}</span>}
{icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true"><SWViewerIcon>{icon}</SWViewerIcon></span>}
{!iconOnly && <span className="hm-sw-viewer-header__label">{label}</span>}
</button>
)
@@ -272,7 +273,7 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
aria-label={moreLabel} title="더보기" aria-haspopup="menu" aria-expanded={moreOpen}
onClick={() => { setMoreFocus("menu"); setMoreOpen(!moreOpen) }}
onKeyDown={event => { if (event.key === "ArrowDown") { event.preventDefault(); setMoreFocus("first"); setMoreOpen(true) } }}>
<span className="hm-sw-viewer-header__icon"><MoreIcon /></span>
<span className="hm-sw-viewer-header__icon"><SWViewerIcon><MoreIcon /></SWViewerIcon></span>
</button>
<SWViewerMenu entries={more} open={moreOpen} onOpenChange={setMoreOpen} anchorRef={moreRef} align="end" width={260} surface="beige" label={moreLabel} initialFocus={moreFocus}
onEntrySelect={entry => {
@@ -0,0 +1,49 @@
import { useLayoutEffect, useRef, type ReactNode } from "react"
import "../../theme/sw-viewer.css"
const SHAPES = "path, line, polyline, polygon, circle, ellipse, rect"
export interface SWViewerIconProps {
/** The program's SVG icon (any size; stroke and / or fill, currentColor recommended). */
children: ReactNode
className?: string
}
/**
* Icon with the SWHeader black outline drawn as SVG stroke (SWHeader icon assets: 1px black OUTSIDE stroke).
* A copy of the icon sits behind it: every shape of the copy is stroked black with its own stroke width plus
* 2 x `--hm-sw-viewer-icon-outline-width` (1px each side, in screen pixels at the rendered size), and filled
* shapes are filled black. The icon on top is untouched, so its color, shape, size and line width stay the same.
*/
export function SWViewerIcon({ children, className }: SWViewerIconProps) {
const outlineRef = useRef<HTMLSpanElement>(null)
useLayoutEffect(() => {
const root = outlineRef.current
if (!root) return
const outline = parseFloat(getComputedStyle(root).getPropertyValue("--hm-sw-viewer-icon-outline-width")) || 1
for (const svg of Array.from(root.querySelectorAll("svg"))) {
const view = svg.viewBox?.baseVal
const rendered = svg.getBoundingClientRect().width
// User units per screen pixel, so the outline is 1px on screen whatever the viewBox (e.g. 24 drawn at 16px).
const unit = view && view.width && rendered ? view.width / rendered : 1
for (const shape of Array.from(svg.querySelectorAll<SVGElement>(SHAPES))) {
shape.style.removeProperty("stroke-width")
const style = getComputedStyle(shape)
const strokeWidth = style.stroke !== "none" ? parseFloat(style.strokeWidth) || 0 : 0
const filled = style.fill !== "none" && Number(style.fillOpacity) !== 0
if (!strokeWidth && !filled) { shape.style.visibility = "hidden"; continue }
shape.style.stroke = "var(--hm-sw-viewer-icon-outline-color)"
shape.style.strokeWidth = `${strokeWidth + 2 * outline * unit}`
shape.style.strokeLinejoin = "round"
if (!strokeWidth) shape.style.strokeLinecap = "round"
if (filled) shape.style.fill = "var(--hm-sw-viewer-icon-outline-color)"
}
}
})
return (
<span className={`hm-sw-viewer-icon${className ? ` ${className}` : ""}`} aria-hidden="true">
<span ref={outlineRef} className="hm-sw-viewer-icon__outline">{children}</span>
<span className="hm-sw-viewer-icon__glyph">{children}</span>
</span>
)
}
@@ -1,16 +1,15 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"
import { SWPopup } from "../sw-popup/SWPopup"
import "../../theme/sw-viewer.css"
export type SWViewerPanelPlacement = "top-end" | "top-start" | "center"
export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
/** Title (14px 700 #FFC600, ellipsis). Also the accessible name. */
/** Title (SWPopup title: 700, #FFC600, black 1px outline, ellipsis). Also the accessible name. */
title: string
/** 16px title icon supplied by the program. */
icon?: ReactNode
/** Shown when true. Unmounted otherwise (no hidden DOM). */
open: boolean
/** Close button, and Escape while focus is inside the panel. */
/** Close button, and Escape (inside the panel, or anywhere while it is the top SW popup — SWPopup behavior). */
onClose: () => void
closeLabel?: string
/** Extra title-bar buttons before collapse / close (use `SWViewerButton variant="ghost"`). */
@@ -19,7 +18,7 @@ export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>,
children?: ReactNode
/** Bottom button area (e.g. 취소 / 적용). Stays visible while the body scrolls. */
footer?: ReactNode
/** Initial size. Default 320 x 420 (reference panel). `height: "auto"` follows the content. */
/** Initial size. Default 314 (the SWPopup width) x 420. `height: "auto"` follows the content. */
width?: number
height?: number | "auto"
minWidth?: number
@@ -48,7 +47,7 @@ export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>,
onSizeChange?: (size: { width: number; height: number }) => void
}
let topLayer = 30
let topLayer = 40
const EDGE = 10
const INTERACTIVE = "button, input, select, textarea, a, label, [data-panel-no-drag]"
@@ -56,8 +55,8 @@ const Chevron = ({ down }: { down: boolean }) => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d={down ? "M4 6l4 4 4-4" : "M6 4l4 4-4 4"} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
function bounds(element: HTMLElement | null, strategy: "fixed" | "absolute") {
const parent = strategy === "absolute" ? element?.offsetParent as HTMLElement | null : null
function bounds(element: HTMLElement, strategy: "fixed" | "absolute") {
const parent = strategy === "absolute" ? element.offsetParent as HTMLElement | null : null
return parent ? { width: parent.clientWidth, height: parent.clientHeight } : { width: document.documentElement.clientWidth, height: window.innerHeight }
}
@@ -70,20 +69,21 @@ function clamp(left: number, top: number, element: HTMLElement, strategy: "fixed
}
/**
* SW viewer popup / settings window (reference DWG/DXF viewer floating panel): #E0DACE body, 2px #897445 border,
* #221D13 title bar with #FFC600 title, collapse and the curved green close (#E40000 on hover). Non-modal by
* default: drag by the title bar, corner resize, collapse, stays inside the bounds, Escape inside closes, the
* last touched panel comes to the front. The frame is fixed; `children` / `footer` are free.
* SW viewer popup / settings window. The frame is the common SW popup (`SWPopup`: title bar, title, close,
* border, radius, shadow — the same component and DOM as every SW popup); this adds the viewer window behavior:
* drag by the title bar, corner resize, collapse, placement inside the bounds, bring to front, optional modal.
* `children` / `footer` are free content.
*/
export function SWViewerPanel({
title, icon, open, onClose, closeLabel = "닫기", headerActions, children, footer,
width = 320, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed",
title, open, onClose, closeLabel = "닫기", headerActions, children, footer,
width = 314, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed",
draggable = true, resizable = true, collapsible = true, defaultCollapsed = false, collapsed: collapsedProp, onCollapsedChange,
modal = false, autoFocus = true, onPositionChange, onSizeChange, className, style, onKeyDown, onPointerDownCapture, onFocusCapture, ...rest
modal = false, autoFocus = true, onPositionChange, onSizeChange, id: idProp, className, style, onKeyDown,
onPointerDownCapture, onFocusCapture, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, ...rest
}: SWViewerPanelProps) {
const ref = useRef<HTMLDivElement>(null)
const titleId = useId()
const closeGradient = `hm-sw-viewer-close-${useId().replace(/:/g, "")}`
const generatedId = useId()
const id = idProp ?? `hm-sw-viewer-panel-${generatedId.replace(/:/g, "")}`
const element = () => document.getElementById(id) as HTMLDivElement | null
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
const [layer, setLayer] = useState(() => ++topLayer)
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed)
@@ -96,10 +96,10 @@ export function SWViewerPanel({
positionRef.current = position
const placement = defaultPosition ?? (modal ? "center" : "top-end")
// Initial position, measured once per opening; then kept inside the bounds when they change.
// Initial position, measured once per opening (before paint); then kept inside the bounds when they change.
useLayoutEffect(() => {
if (!open) { setPosition(null); setSize(null); return }
const panel = ref.current
const panel = element()
if (!panel) return
const box = bounds(panel, strategy)
const top = inset?.top ?? 56, side = inset?.side ?? 24
@@ -107,7 +107,7 @@ export function SWViewerPanel({
: placement === "center" ? { left: (box.width - panel.offsetWidth) / 2, top: (box.height - panel.offsetHeight) / 2 }
: { left: placement === "top-end" ? box.width - panel.offsetWidth - side : side, top }
setPosition(clamp(start.left, start.top, panel, strategy))
const keepInside = () => { const current = positionRef.current; if (current && ref.current) setPosition(clamp(current.left, current.top, ref.current, strategy)) }
const keepInside = () => { const current = positionRef.current, node = element(); if (current && node) setPosition(clamp(current.left, current.top, node, strategy)) }
window.addEventListener("resize", keepInside)
const parent = strategy === "absolute" ? panel.offsetParent : null
const observer = parent ? new ResizeObserver(keepInside) : null
@@ -115,46 +115,52 @@ export function SWViewerPanel({
return () => { window.removeEventListener("resize", keepInside); observer?.disconnect() }
}, [open])
// Focus in once positioned (a hidden panel cannot take focus), back to the opener on close.
const placed = position !== null
useEffect(() => {
if (!open || !autoFocus) return
const previous = document.activeElement as HTMLElement | null
return () => { if (previous?.isConnected) previous.focus({ preventScroll: true }) }
}, [open, autoFocus])
useEffect(() => {
if (open && autoFocus && placed && !ref.current?.contains(document.activeElement)) ref.current?.focus({ preventScroll: true })
}, [open, autoFocus, placed])
if (!open) return null
const raise = () => { if (layer !== topLayer) setLayer(++topLayer) }
const setCollapsed = (next: boolean) => { if (collapsedProp === undefined) setInternalCollapsed(next); onCollapsedChange?.(next) }
const onTitlebar = (target: EventTarget) => {
const node = target as HTMLElement
return !!node.closest(".hm-sw-property__titlebar") && !node.closest(INTERACTIVE)
}
const onTitlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest(INTERACTIVE) || !ref.current || !position) return
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
onPointerDown?.(event)
if (!draggable || event.button !== 0 || !position || !onTitlebar(event.target)) return
drag.current = { x: event.clientX, y: event.clientY, left: position.left, top: position.top }
event.currentTarget.setPointerCapture(event.pointerId)
event.currentTarget.dataset.dragging = ""
}
const onTitlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const start = drag.current
if (!start || !ref.current) return
setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, ref.current, strategy))
const moveDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
onPointerMove?.(event)
const start = drag.current, panel = element()
if (!start || !panel) return
setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, panel, strategy))
}
const onTitlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
onPointerUp?.(event)
if (!drag.current) return
drag.current = null
delete event.currentTarget.dataset.dragging
if (positionRef.current) onPositionChange?.(positionRef.current)
}
const toggleByTitle = (event: ReactMouseEvent<HTMLDivElement>) => {
onDoubleClick?.(event)
// Pointer capture (drag) retargets the click to the panel root, so test what is under the pointer.
const under = document.elementFromPoint(event.clientX, event.clientY)
if (collapsible && under && onTitlebar(under)) setCollapsed(!collapsed)
}
const onGripPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !ref.current) return
const panel = element()
if (event.button !== 0 || !panel) return
event.preventDefault()
resize.current = { x: event.clientX, y: event.clientY, width: ref.current.offsetWidth, height: ref.current.offsetHeight }
event.stopPropagation()
resize.current = { x: event.clientX, y: event.clientY, width: panel.offsetWidth, height: panel.offsetHeight }
event.currentTarget.setPointerCapture(event.pointerId)
}
const onGripPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const start = resize.current, panel = ref.current
event.stopPropagation()
const start = resize.current, panel = element()
if (!start || !panel || !position) return
const box = bounds(panel, strategy)
// Min size, 90% of the bounds, and never past the bounds edge (10px) from the current corner.
@@ -164,73 +170,59 @@ export function SWViewerPanel({
height: Math.round(Math.max(minHeight, Math.min(maxHeight, start.height + event.clientY - start.y))),
})
}
const onGripPointerUp = () => {
const onGripPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation()
if (!resize.current) return
resize.current = null
if (ref.current) onSizeChange?.({ width: ref.current.offsetWidth, height: ref.current.offsetHeight })
const panel = element()
if (panel) onSizeChange?.({ width: panel.offsetWidth, height: panel.offsetHeight })
}
const onPanelKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const trapFocus = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented || event.nativeEvent.isComposing) return
if (event.key === "Escape") { event.stopPropagation(); onClose(); return }
if (modal && event.key === "Tab" && ref.current) {
const focusables = Array.from(ref.current.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])'))
if (!focusables.length) { event.preventDefault(); return }
const first = focusables[0], last = focusables[focusables.length - 1]
if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
const panel = element()
if (!modal || event.key !== "Tab" || event.defaultPrevented || !panel) return
const focusables = Array.from(panel.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])'))
if (!focusables.length) { event.preventDefault(); return }
const first = focusables[0], last = focusables[focusables.length - 1]
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
const currentWidth = size?.width ?? width
const frameStyle: CSSProperties = {
position: strategy,
left: position?.left ?? 0,
top: position?.top ?? 0,
zIndex: layer,
width: size?.width ?? width,
width: currentWidth,
height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height,
minWidth,
minHeight: collapsed ? undefined : Math.min(minHeight, typeof height === "number" ? height : minHeight),
visibility: position ? undefined : "hidden",
maxWidth: "90vw",
maxHeight: "90vh",
["--hm-sw-viewer-panel-width" as string]: `${currentWidth}px`,
...style,
}
const panel = (
<div ref={ref} role="dialog" aria-modal={modal} aria-labelledby={titleId} tabIndex={-1}
className={`hm-sw-theme hm-sw-viewer-panel${className ? ` ${className}` : ""}`} data-collapsed={collapsed || undefined} data-modal={modal || undefined}
style={frameStyle} {...rest}
onKeyDown={onPanelKeyDown}
<SWPopup {...rest} id={id} title={title} onClose={onClose} closeLabel={closeLabel} autoFocus={autoFocus}
className={`hm-sw-theme hm-sw-viewer-panel${className ? ` ${className}` : ""}`} style={frameStyle}
aria-modal={modal}
data-collapsed={collapsed || undefined} data-modal={modal || undefined} data-draggable={draggable || undefined}
onKeyDown={trapFocus}
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}>
<div className="hm-sw-viewer-panel__titlebar" data-draggable={draggable || undefined}
onPointerDown={onTitlePointerDown} onPointerMove={onTitlePointerMove} onPointerUp={onTitlePointerUp} onPointerCancel={onTitlePointerUp}
onDoubleClick={event => { if (collapsible && !(event.target as HTMLElement).closest(INTERACTIVE)) setCollapsed(!collapsed) }}>
<h2 id={titleId} className="hm-sw-viewer-panel__title" title={title}>
{icon && <span className="hm-sw-viewer-panel__title-icon" aria-hidden="true">{icon}</span>}
<span className="hm-sw-viewer-panel__title-text">{title}</span>
</h2>
<div className="hm-sw-viewer-panel__actions">
{headerActions}
{collapsible && <button type="button" className="hm-sw-viewer-panel__icon-button" aria-label={collapsed ? "펼치기" : "접기"} title={collapsed ? "펼치기" : "접기"} aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}><Chevron down={!collapsed} /></button>}
<button type="button" className="hm-sw-viewer-panel__close" aria-label={closeLabel} title={closeLabel} onClick={onClose}>
<svg className="hm-sw-viewer-panel__close-shape" viewBox="0 0 52 34" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<defs>
<radialGradient id={closeGradient} cx="55%" cy="30%" r="85%">
<stop offset="0%" stopColor="var(--hm-sw-viewer-close-glow)" />
<stop offset="100%" stopColor="var(--hm-sw-viewer-close-edge)" />
</radialGradient>
</defs>
<path d="M 0 0 C 12 0 14 34 26 34 H 52 V 0 Z" fill={`url(#${closeGradient})`} />
<path className="hm-sw-viewer-panel__close-curve" d="M 0 0 C 12 0 14 34 26 34" fill="none" vectorEffect="non-scaling-stroke" />
</svg>
<svg className="hm-sw-viewer-panel__close-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /></svg>
</button>
</div>
</div>
{!collapsed && <div className="hm-sw-viewer-panel__body">{children}</div>}
{!collapsed && footer && <div className="hm-sw-viewer-panel__footer">{footer}</div>}
{resizable && !collapsed && <div className="hm-sw-viewer-panel__resize" aria-hidden="true" data-panel-no-drag=""
onPointerDown={onGripPointerDown} onPointerMove={onGripPointerMove} onPointerUp={onGripPointerUp} onPointerCancel={onGripPointerUp} />}
</div>
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}
onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={endDrag} onDoubleClick={toggleByTitle}
titleActions={(headerActions || collapsible) && <div className="hm-sw-viewer-panel__actions">
{headerActions}
{collapsible && <button type="button" className="hm-sw-viewer-panel__icon-button" aria-label={collapsed ? "펼치기" : "접기"} title={collapsed ? "펼치기" : "접기"} aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}><Chevron down={!collapsed} /></button>}
</div>}
footer={<>
{footer && !collapsed && <div className="hm-sw-viewer-panel__footer">{footer}</div>}
{resizable && !collapsed && <div className="hm-sw-viewer-panel__resize" aria-hidden="true" data-panel-no-drag=""
onPointerDown={onGripPointerDown} onPointerMove={onGripPointerMove} onPointerUp={onGripPointerUp} onPointerCancel={onGripPointerUp} />}
</>}>
{collapsed ? null : children}
</SWPopup>
)
return modal ? <><div className="hm-sw-viewer-panel-backdrop" style={{ position: strategy, zIndex: layer }} aria-hidden="true" />{panel}</> : panel
}
@@ -1,5 +1,6 @@
export { SWViewerHeader, SWViewerHeaderButton, type SWViewerHeaderProps, type SWViewerHeaderButtonProps } from "./SWViewerHeader"
export { SWViewerMenu, type SWViewerMenuProps } from "./SWViewerMenu"
export { SWViewerIcon, type SWViewerIconProps } from "./SWViewerIcon"
export { SWViewerPanel, type SWViewerPanelProps, type SWViewerPanelPlacement } from "./SWViewerPanel"
export {
SWViewerSection, SWViewerField, SWViewerHint, SWViewerSwitch, SWViewerSlider, SWViewerInput, SWViewerButton, SWViewerBadge, SWViewerStatus,
+52 -114
View File
@@ -29,15 +29,9 @@
--hm-sw-viewer-switch-off: #0a221d;
--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);
/* Icon stroke: the SWHeader icon assets (print / K guide / layer icons) are a fill + a 1px black OUTSIDE stroke
(Figma mask "outside" path) + a 0-offset glow (stdDeviation 1.615, #061A14). Program icons are free SVGs, so the
same stroke is a drop-shadow chain that follows the icon's own contour: the four 1px shadows dilate the shape
by 1px (each one also shadows the previous ones, so corners close), the last one is the glow. */
(Figma mask "outside" path). SWViewerIcon draws it as SVG stroke on a copy behind the icon (see SWViewerIcon). */
--hm-sw-viewer-icon-outline-color: var(--hm-sw-header-text-outline-color);
--hm-sw-viewer-icon-glow: #061a14;
--hm-sw-viewer-icon-outline:
drop-shadow(1px 0 0 var(--hm-sw-viewer-icon-outline-color)) drop-shadow(-1px 0 0 var(--hm-sw-viewer-icon-outline-color))
drop-shadow(0 1px 0 var(--hm-sw-viewer-icon-outline-color)) drop-shadow(0 -1px 0 var(--hm-sw-viewer-icon-outline-color))
drop-shadow(0 0 1.615px var(--hm-sw-viewer-icon-glow));
--hm-sw-viewer-icon-outline-width: 1px;
/* Dropdown (light) and More (beige) */
--hm-sw-viewer-menu-background: #fff;
@@ -51,31 +45,20 @@
--hm-sw-viewer-more-background: #f5f0e5;
--hm-sw-viewer-more-border-color: #897445;
/* Panel */
--hm-sw-viewer-panel-background: #e0dace;
--hm-sw-viewer-panel-field-background: #f5f0e5;
--hm-sw-viewer-panel-border: #897445;
--hm-sw-viewer-panel-inner-border: #9a9284;
--hm-sw-viewer-panel-title-background: #221d13;
--hm-sw-viewer-panel-title-highlight: #a98d53;
--hm-sw-viewer-panel-title-color: #ffc600;
--hm-sw-viewer-panel-title-button-hover: #604f32;
--hm-sw-viewer-panel-text: #1d1810;
/* Panel: the frame is SWPopup (title bar, title, close, border, radius, shadow). Content tokens map to the
SW property window tokens so viewer content sits on the same surface as every SW popup. */
--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-spacing: 12px;
--hm-sw-viewer-panel-title-height: 34px;
--hm-sw-viewer-panel-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
--hm-sw-viewer-panel-primary: #123328;
--hm-sw-viewer-panel-switch-off: #9a9284;
--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: #f4f1ea;
--hm-sw-viewer-panel-badge-background: var(--hm-sw-property-table);
--hm-sw-viewer-panel-overlay: rgba(0, 0, 0, 0.1);
/* Close: green curve, #E40000 on hover (user-supplied reference value). */
--hm-sw-viewer-close-glow: #123328;
--hm-sw-viewer-close-edge: #020a07;
--hm-sw-viewer-close-hover: #e40000;
--hm-sw-viewer-close-icon: #999;
--hm-sw-viewer-panel-spacing: 12px;
}
.hm-sw-viewer-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@@ -194,6 +177,13 @@ button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-header__icon, .hm-sw-viewer-header__chevron { display: inline-flex; width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-header__icon > svg, .hm-sw-viewer-header__chevron > svg { width: 100%; height: 100%; }
/* SWViewerIcon: the outline copy (black SVG stroke) right behind the untouched icon. */
.hm-sw-viewer-icon { position: relative; display: inline-flex; width: 100%; height: 100%; }
.hm-sw-viewer-icon__outline, .hm-sw-viewer-icon__glyph { display: inline-flex; width: 100%; height: 100%; }
.hm-sw-viewer-icon__outline { position: absolute; inset: 0; overflow: visible; }
.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. */
.hm-sw-viewer-header__tabs { display: flex; align-items: center; gap: var(--hm-sw-viewer-gap); }
.hm-sw-viewer-header .hm-sw-viewer-header__tab {
@@ -233,7 +223,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
/* 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: --hm-sw-viewer-icon-outline (see the token). Fill color, size,
(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. */
.hm-sw-viewer-header :is(
@@ -244,9 +234,6 @@ button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-header :is(.hm-sw-viewer-header__count, .hm-sw-viewer-header__switch-label, .hm-sw-viewer-status__label) {
text-shadow: var(--hm-sw-header-label-outline);
}
.hm-sw-viewer-header :is(.hm-sw-viewer-header__icon, .hm-sw-viewer-header__chevron, .hm-sw-viewer-status > svg) {
filter: var(--hm-sw-viewer-icon-outline);
}
/* Clipped (ellipsis) file name: the outline may paint 2px outside the box instead of being cut. */
.hm-sw-viewer-header .hm-sw-viewer-header__document-name {
overflow: clip;
@@ -264,7 +251,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
font-weight: 400;
white-space: nowrap;
}
.hm-sw-viewer-status > svg { width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-status__icon { display: inline-flex; width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-status[data-state="error"] { color: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-status__spin { animation: hm-sw-viewer-spin 1s linear infinite; }
@keyframes hm-sw-viewer-spin { to { transform: rotate(360deg); } }
@@ -356,105 +343,52 @@ button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-menu__separator { flex: 0 0 1px; height: 1px; margin: 4px -4px; background: var(--hm-sw-viewer-menu-border-color); }
.hm-sw-viewer-menu[data-surface="beige"] .hm-sw-viewer-menu__separator { background: var(--hm-sw-viewer-panel-inner-border); }
/* ---------- Panel ---------- */
.hm-sw-viewer-panel {
display: flex;
flex-direction: column;
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
border: 2px solid var(--hm-sw-viewer-panel-border);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-panel-background);
box-shadow: var(--hm-sw-viewer-panel-shadow), inset 0 0 0 1px var(--hm-sw-viewer-panel-field-background);
color: var(--hm-sw-viewer-panel-text);
font-size: 14px;
font-weight: 400;
line-height: 20px;
outline: none;
}
/* ---------- Panel (SWPopup frame + viewer window behavior) ---------- */
.hm-sw-viewer-panel-backdrop { inset: 0; background: var(--hm-sw-viewer-panel-overlay); }
.hm-sw-viewer-panel__titlebar {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 4px;
min-height: var(--hm-sw-viewer-panel-title-height);
padding-left: var(--hm-sw-viewer-panel-spacing);
border-bottom: 1px solid var(--hm-sw-viewer-panel-border);
background: var(--hm-sw-viewer-panel-title-background);
box-shadow: inset 0 1px 0 var(--hm-sw-viewer-panel-title-highlight);
user-select: none;
.hm-sw-viewer-panel[data-collapsed] .hm-sw-property__body { display: none; }
.hm-sw-viewer-panel[data-draggable] .hm-sw-property__titlebar { cursor: grab; touch-action: none; user-select: none; }
.hm-sw-viewer-panel[data-dragging] .hm-sw-property__titlebar { cursor: grabbing; }
/* Any width: the Figma title art keeps its 308px curve at the close button (right-aligned; at 314px this is exactly
the SWPopup position), and a stretched copy of the same art fills the flat part on the left of wider panels.
The right one hides its own left 20px (left edge + corner) and the left copy ends exactly there: no seam, no
doubled shadow. */
.hm-sw-viewer-panel .hm-sw-property__title-art { left: auto; right: 4px; clip-path: inset(-4px -4px -4px 20px); }
.hm-sw-viewer-panel .hm-sw-property__titlebar::before {
content: "";
position: absolute;
z-index: 1;
left: 0;
top: -2px;
width: max(0px, calc(100% - 292px));
height: 38px;
background: url("../assets/property-title-single.svg") left top / calc(100% + 288px) 38px no-repeat;
transform: scaleY(-1);
pointer-events: none;
}
.hm-sw-viewer-panel__titlebar[data-draggable] { cursor: grab; touch-action: none; }
.hm-sw-viewer-panel__titlebar[data-dragging] { cursor: grabbing; }
.hm-sw-viewer-panel__title {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
margin: 0;
color: var(--hm-sw-viewer-panel-title-color);
font-size: 14px;
font-weight: 700;
line-height: 1.4;
}
.hm-sw-viewer-panel__title-icon { display: inline-flex; flex: 0 0 16px; width: 16px; height: 16px; }
.hm-sw-viewer-panel__title-icon > svg { width: 100%; height: 100%; }
.hm-sw-viewer-panel__title-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.hm-sw-viewer-panel__actions { display: flex; flex-shrink: 0; align-items: center; }
.hm-sw-viewer-panel__actions { position: relative; z-index: 2; display: flex; flex-shrink: 0; align-items: center; height: 30px; }
/* Title-bar buttons: SW text-only look (no face), #FFC600 on hover like the SW title-bar controls. */
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--hm-sw-viewer-panel-title-height);
height: var(--hm-sw-viewer-panel-title-height);
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--hm-sw-viewer-muted);
}
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):hover:not(:disabled) { background: var(--hm-sw-viewer-panel-title-button-hover); color: var(--hm-sw-viewer-panel-title-color); }
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"], .hm-sw-viewer-panel__close):focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-title-color); outline-offset: -3px; }
.hm-sw-viewer-panel .hm-sw-viewer-panel__close {
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
flex-shrink: 0;
width: calc(var(--hm-sw-viewer-panel-title-height) * 1.53);
height: var(--hm-sw-viewer-panel-title-height);
padding: 0 0 0 calc(var(--hm-sw-viewer-panel-title-height) * 0.7);
overflow: hidden;
color: var(--hm-sw-viewer-close-icon);
}
.hm-sw-viewer-panel__close-shape { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.hm-sw-viewer-panel__close-curve { stroke: var(--hm-sw-viewer-panel-border); stroke-width: 1; }
.hm-sw-viewer-panel__close-icon { position: relative; width: 16px; height: 16px; pointer-events: none; }
.hm-sw-viewer-panel .hm-sw-viewer-panel__close:focus-visible { --hm-sw-viewer-close-glow: #266651; color: #fff; }
.hm-sw-viewer-panel .hm-sw-viewer-panel__close:is(:hover, :active) { --hm-sw-viewer-close-glow: var(--hm-sw-viewer-close-hover); --hm-sw-viewer-close-edge: var(--hm-sw-viewer-close-hover); color: #fff; }
.hm-sw-viewer-panel__body {
display: flex;
flex-direction: column;
gap: 20px;
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: var(--hm-sw-viewer-panel-spacing);
scrollbar-width: thin;
scrollbar-color: var(--hm-sw-viewer-panel-inner-border) var(--hm-sw-viewer-panel-background);
}
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):hover:not(:disabled) { background: transparent; color: var(--hm-sw-header-selected-color); }
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):focus-visible { outline: 2px solid var(--hm-sw-header-selected-color); outline-offset: -2px; }
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) svg { width: 16px; height: 16px; }
.hm-sw-viewer-panel__footer {
display: flex;
flex-shrink: 0;
justify-content: flex-end;
gap: 8px;
padding: 8px var(--hm-sw-viewer-panel-spacing);
padding: 7px;
border-top: 1px solid var(--hm-sw-viewer-panel-inner-border);
background: var(--hm-sw-viewer-panel-background);
}
/* Panel content */
@@ -525,6 +459,10 @@ button.hm-sw-viewer-header__brand { text-align: left; }
}
.hm-sw-theme .hm-sw-viewer-button[data-variant="outline"]:hover:not(:disabled) { background: var(--hm-sw-viewer-panel-field-background); }
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"] { background: var(--hm-sw-viewer-panel-primary); color: #fff; }
/* tone="green" (e.g. 전체 켜기 / 전체 끄기): default unchanged. Hover = the primary (확인) button hover, the same token;
pressed = the primary button face. White text as on the primary button. Back to default / hover after the press. */
.hm-sw-theme .hm-sw-viewer-button[data-tone="green"]:hover:not(:disabled) { border-color: var(--hm-sw-viewer-hover); background: var(--hm-sw-viewer-hover); color: #fff; }
.hm-sw-theme .hm-sw-viewer-button[data-tone="green"]:active:not(:disabled) { border-color: var(--hm-sw-viewer-panel-primary); background: var(--hm-sw-viewer-panel-primary); color: #fff; }
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"]:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); border-color: var(--hm-sw-viewer-hover); }
.hm-sw-theme .hm-sw-viewer-button:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: 1px; }
.hm-sw-theme .hm-sw-viewer-button:disabled { opacity: 0.5; cursor: not-allowed; }
+34 -19
View File
@@ -1,5 +1,7 @@
// Regression for the SW viewer work (2026-10-19): the existing SW/Header Stories must render pixel-identical
// before (base Storybook) and after (this branch) at several widths.
// Regression for the SW viewer work (2026-10-19): the existing SW/Header and SW/Popup Stories must render their
// headers and popups pixel-identical before (base Storybook) and after (this branch) at several widths.
// Element mode compares every .hm-sw-header / .hm-sw-property element; the full page is reported for information
// (the 2026-10-19 follow-up changed the Story work-area background to the shared grid canvas on purpose).
// Usage: node packages/sw/tests/viewer-regression.browser.mjs <base-storybook-url> <new-storybook-url> <out-dir>
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
@@ -10,38 +12,51 @@ const require = createRequire(path.join(runtime, 'index.js'))
const { chromium } = require('playwright')
const { PNG } = require('pngjs')
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
const [base, next, outArg] = process.argv.slice(2)
const [base, next, outArg, mode] = process.argv.slice(2)
// --neutral-canvas: on the new side only, put back the pre-grid Story backgrounds (white work area, page color under
// dropdowns), so semi-transparent header / dropdown edge pixels compare against the same backdrop.
const neutral = mode === "--neutral-canvas"
const out = path.resolve(outArg ?? 'docs/sw/viewer-qa/regression')
await mkdir(out, { recursive: true })
const ids = ['overview', 'interactive-states', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls', 'document-open', 'hidden-sub-controls', 'detail-buttons']
const ids = ['overview', 'interactive-states', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls', 'document-open', 'hidden-sub-controls', 'detail-buttons'].map(id => `sw-header--${id}`)
.concat(['sw-popup--custom-content', 'sw-popup--multiple-popups'])
const widths = [1920, 1180, 768]
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const shot = async (url, width) => {
const shot = async (url, width, restore = false) => {
const page = await browser.newPage({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' })
await page.goto(url)
await page.waitForSelector('#storybook-root > *')
if (restore) await page.addStyleTag({ content: '.sw-overview-stage { background: var(--hm-sem-bg-surface) !important; } .sw-story-scroller--menu { background: none !important; }' })
await page.evaluate(() => document.fonts.ready)
await page.waitForTimeout(400)
const buffer = await page.screenshot({ fullPage: true, animations: 'disabled' })
const full = PNG.sync.read(await page.screenshot({ fullPage: true, animations: 'disabled' }))
const elements = []
for (const element of await page.locator('.hm-sw-header, .hm-sw-property').all()) {
if (!(await element.isVisible())) continue
elements.push(PNG.sync.read(await element.screenshot({ animations: 'disabled' })))
}
await page.close()
return PNG.sync.read(buffer)
return { full, elements }
}
const compare = (a, b, out) => {
if (a.width !== b.width || a.height !== b.height) return -1
const image = new PNG({ width: a.width, height: a.height })
const diff = pixelmatch(a.data, b.data, image.data, a.width, a.height, { threshold: 0 })
if (diff && out) return { diff, image }
return diff
}
const results = []
for (const id of ids) for (const width of widths) {
const url = root => `${root}/iframe.html?id=sw-header--${id}&viewMode=story`
const [a, b] = [await shot(url(base), width), await shot(url(next), width)]
const same = a.width === b.width && a.height === b.height
let diff = same ? 0 : -1
if (same) {
const image = new PNG({ width: a.width, height: a.height })
diff = pixelmatch(a.data, b.data, image.data, a.width, a.height, { threshold: 0 })
if (diff) await writeFile(path.join(out, `${id}-${width}-diff.png`), PNG.sync.write(image))
}
results.push({ id, width, size: `${a.width}x${a.height} / ${b.width}x${b.height}`, diffPixels: diff, result: diff === 0 ? 'PASS' : 'FAIL' })
console.log(`${diff === 0 ? 'PASS' : 'FAIL'} sw-header--${id} @${width} diff ${diff}`)
const url = root => `${root}/iframe.html?id=${id}&viewMode=story`
const [a, b] = [await shot(url(base), width), await shot(url(next), width, neutral)]
const elementDiffs = a.elements.length === b.elements.length ? a.elements.map((item, index) => compare(item, b.elements[index])) : [-1]
const fullDiff = compare(a.full, b.full)
const pass = elementDiffs.every(diff => diff === 0)
results.push({ id, width, elements: a.elements.length + ' / ' + b.elements.length, elementDiffPixels: elementDiffs, fullPageDiffPixels: fullDiff, result: pass ? 'PASS' : 'FAIL' })
console.log(`${pass ? 'PASS' : 'FAIL'} ${id} @${width} elements ${a.elements.length}/${b.elements.length} diff [${elementDiffs.join(',')}] full-page diff ${fullDiff}`)
}
await browser.close()
await writeFile(path.join(out, 'regression.json'), JSON.stringify({ base, next, results }, null, 2) + '\n')
await writeFile(path.join(out, neutral ? 'regression-neutral-canvas.json' : 'regression.json'), JSON.stringify({ base, next, results }, null, 2) + '\n')
const failed = results.filter(item => item.result !== 'PASS').length
console.log(`${results.length - failed}/${results.length} PASS`)
process.exit(failed ? 1 : 0)
+31 -7
View File
@@ -157,7 +157,7 @@ if (comparison.reference) {
check("corner resize changes size", resized.width < sized.width - 40 && resized.height < sized.height - 80, `${Math.round(sized.width)}x${Math.round(sized.height)} → ${Math.round(resized.width)}x${Math.round(resized.height)}`)
// Drag: title bar moves the panel and it stays inside the frame.
const before = await box(layerPanel)
const title = layerPanel.locator('.hm-sw-viewer-panel__titlebar')
const title = layerPanel.locator('.hm-sw-property__titlebar')
const titleBox = await box(title)
await page.mouse.move(titleBox.x + 40, titleBox.y + 15)
await page.mouse.down()
@@ -175,16 +175,29 @@ if (comparison.reference) {
`right gap ${Math.round(frame.x + frame.width - (clamped.x + clamped.width))}, bottom gap ${Math.round(frame.y + frame.height - (clamped.y + clamped.height))}`)
// Collapse by button and double click.
await layerPanel.getByRole('button', { name: '접기' }).click()
check('collapse hides body', (await layerPanel.locator('.hm-sw-viewer-panel__body').count()) === 0)
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-viewer-panel__body').count()) === 1)
check('title double click expands', await layerPanel.locator('.hm-sw-property__body').isVisible())
// Panel content works (layer eye).
// 전체 켜기 / 전체 끄기: green on hover and while pressed, default otherwise; other panel buttons unchanged.
const allOn = layerPanel.getByRole('button', { name: '전체 켜기' })
const face = locator => locator.evaluate(element => [getComputedStyle(element).backgroundColor, getComputedStyle(element).color].join(' '))
const restFace = await face(allOn)
await allOn.hover()
const hoverFace = await face(allOn)
await page.mouse.down()
const activeFace = await face(allOn)
await page.mouse.up()
const afterFace = await face(allOn)
await page.mouse.move(5, 5)
const leftFace = await face(allOn)
check('전체 켜기: default kept, green hover / active, back after release', restFace === 'rgb(255, 255, 255) rgb(18, 51, 40)' && hoverFace === 'rgb(38, 102, 81) rgb(255, 255, 255)' && activeFace === 'rgb(18, 51, 40) rgb(255, 255, 255)' && afterFace === hoverFace && leftFace === restFace, [restFace, hoverFace, activeFace, afterFace, leftFace].join(' | '))
await layerPanel.getByRole('button', { name: 'CR-TEXT 숨기기' }).click()
check('panel content callback', (await log(page)).includes('layer:CR-TEXT=false'))
// Close button hover face.
const close = layerPanel.getByRole('button', { name: '닫기' })
await close.hover()
check('close hover #E40000 / white X', (await close.evaluate(element => [getComputedStyle(element).getPropertyValue('--hm-sw-viewer-close-glow').trim(), getComputedStyle(element).color].join(' '))) === '#e40000 rgb(255, 255, 255)')
check('close hover = SWPopup hover asset', await close.evaluate(element => getComputedStyle(element.querySelector('.hm-sw-property__close-hover')).visibility === 'visible' && getComputedStyle(element.querySelector('.hm-sw-property__close-default')).visibility === 'hidden'))
await close.click()
check('close button closes', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
// Two panels: the last touched comes to the front.
@@ -192,12 +205,17 @@ if (comparison.reference) {
await page.getByRole('button', { name: '설정', exact: true }).click()
const options = page.getByRole('dialog', { name: '옵션' })
const z = async () => Promise.all([layerPanel, options].map(item => item.evaluate(element => Number(getComputedStyle(element).zIndex))))
await layerPanel.locator('.hm-sw-viewer-panel__titlebar').click({ position: { x: 30, y: 15 } })
await layerPanel.locator('.hm-sw-property__titlebar').click({ position: { x: 30, y: 15 } })
const [zl, zo] = await z()
check('last touched panel on top', zl > zo, `layer z ${zl}, options z ${zo}`)
// Modal popup: backdrop, focus trap, Escape.
await page.getByRole('button', { name: '정보 및 라이선스' }).click()
const about = page.getByRole('dialog', { name: /라이선스/ })
const confirm = about.getByRole('button', { name: '확인' })
await confirm.hover()
const confirmHover = await face(confirm)
check('전체 켜기 hover = 라이선스 확인 hover (same token)', confirmHover === hoverFace, `${confirmHover} / ${hoverFace}`)
await about.locator('.hm-sw-property__title').hover()
check('modal popup opens with backdrop', (await about.getAttribute('aria-modal')) === 'true' && (await page.locator('.hm-sw-viewer-panel-backdrop').count()) === 1)
for (let index = 0; index < 4; index++) await page.keyboard.press('Tab')
check('modal focus stays inside', await about.evaluate(element => element.contains(document.activeElement)))
@@ -343,7 +361,13 @@ if (comparison.reference) {
return {
label: style('.hm-sw-viewer-header__label').textShadow, document: style('.hm-sw-viewer-header__document-name').textShadow,
tab: style('.hm-sw-viewer-header__tab').textShadow, caption: style('.hm-sw-viewer-header__switch-label').textShadow,
count: style('.hm-sw-viewer-header__count').textShadow, icon: style('.hm-sw-viewer-header__icon').filter, chevron: style('.hm-sw-viewer-header__chevron').filter,
count: style('.hm-sw-viewer-header__count').textShadow,
icon: (() => {
const icon = header.querySelector('.hm-sw-viewer-header__icon')
const copy = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__outline svg *')), glyph = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__glyph svg *'))
// lucide 24-unit icon drawn at 16px: 1px each side = 1.5 units, so the copy is 2 + 3 units.
return { filter: getComputedStyle(icon).filter, copyStroke: copy.stroke, copyWidth: parseFloat(copy.strokeWidth), glyphStroke: glyph.stroke, glyphWidth: parseFloat(glyph.strokeWidth) }
})(),
labelColor: style('.hm-sw-viewer-header__label').color,
labelFont: [style('.hm-sw-viewer-header__label').fontSize, style('.hm-sw-viewer-header__label').fontWeight, style('.hm-sw-viewer-header__label').letterSpacing].join(' '),
}
@@ -351,7 +375,7 @@ if (comparison.reference) {
const blacks = value => value.split('rgb(0, 0, 0)').length - 1
check('text outline 1.3px black on 14px text (label / file name / tab)', [outline.label, outline.document, outline.tab].every(value => value.includes('rgb(0, 0, 0) 1.3px 0px 0px') && blacks(value) === 16), outline.label.slice(0, 60))
check('label outline 1px black on 11px captions / counts', [outline.caption, outline.count].every(value => value.includes('rgb(0, 0, 0) 1px 0px 0px') && blacks(value) === 16), outline.caption.slice(0, 60))
check('icon outline: 4 x 1px black drop-shadows + #061A14 glow', [outline.icon, outline.chevron].every(value => (value.match(/drop-shadow/g) ?? []).length === 5 && value.includes('rgb(0, 0, 0) 1px 0px 0px') && value.includes('rgb(6, 26, 20)')), outline.icon.slice(0, 90))
check('icon outline: SVG stroke copy, no CSS filter', outline.icon.filter === 'none' && outline.icon.copyStroke === 'rgb(0, 0, 0)' && outline.icon.copyWidth === outline.icon.glyphWidth + 3 && outline.icon.glyphStroke !== 'rgb(0, 0, 0)', JSON.stringify(outline.icon))
check('fill / size / weight / spacing kept', outline.labelColor === 'rgb(255, 255, 255)' && outline.labelFont === '14px 500 normal', outline.labelColor + ' ' + outline.labelFont)
await page.close()
}
+5 -2
View File
@@ -1,6 +1,9 @@
/* Story canvas only: frames that simulate a header width. Never styles the header itself. */
.sw-story-scroller { max-width: 100%; overflow-x: auto; }
.sw-story-frame { background: #060606; }
/* Shared drawing-area canvas of the SW Stories (2026-10-19): the SW/Viewer black grid — 40px cells, 1px lines of
HM green #266651 at 18%, on #0A0A0B. Used behind the SW Header work area and dropdown room and the viewer frames. */
.sb-page { --sw-story-canvas: linear-gradient(rgba(38, 102, 81, 0.18) 1px, transparent 1px) 0 0 / 40px 40px, linear-gradient(90deg, rgba(38, 102, 81, 0.18) 1px, transparent 1px) 0 0 / 40px 40px, #0a0a0b; }
.sw-story-composed {
display: flex;
align-items: center;
@@ -10,7 +13,7 @@
padding: 5px 8px;
background: var(--hm-sw-header-object-background);
}
.sw-story-scroller--menu { padding-bottom: 360px; }
.sw-story-scroller--menu { padding-bottom: 360px; background: var(--sw-story-canvas); }
/* Only the two fixed dimension specimens use the full 1920px frame as their anchor. The shared
runtime dropdown still clamps to the real viewport. Scroll the specimen to see its right edge. */
.sw-story-fixed-dimension .hm-sw-header__list-menu--dimension-style { left: auto !important; right: 0; }
@@ -58,7 +61,7 @@
.sw-overview-data { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding-block: 8px; }
.sw-overview-data label { display: inline-flex; align-items: center; gap: 4px; }
.sw-overview-slot { flex: 1 1 0; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; }
.sw-overview-stage { flex: 0 0 auto; overflow: auto; overscroll-behavior: contain; background: var(--hm-sem-bg-surface); outline: 1px solid var(--hm-sem-stroke-input-default); }
.sw-overview-stage { flex: 0 0 auto; overflow: auto; overscroll-behavior: contain; background: var(--sw-story-canvas); outline: 1px solid var(--hm-sem-stroke-input-default); }
.sw-overview-world { min-width: 390px; min-height: 100%; position: relative; }
/* Placement-only Storybook exception: keep the original panel size/content in the Stage scroll area.
Header's public API deliberately excludes panel style; package CSS and other Stories stay untouched. */
+5 -5
View File
@@ -106,12 +106,12 @@ export function LayerPanel({ demo, ...placement }: { demo: ViewerDemo } & PanelP
const filtered = LAYERS.filter(layer => layer.name.toLowerCase().includes(query.trim().toLowerCase()))
const set = (names: string[]) => demo.setHidden(new Set(names))
return (
<SWViewerPanel title={`레이어 (${LAYERS.length}개)`} icon={<Layers />} open={demo.panels.layers} onClose={() => demo.toggle("layers", false)} {...placement}>
<SWViewerPanel title={`레이어 (${LAYERS.length}개)`} open={demo.panels.layers} onClose={() => demo.toggle("layers", false)} {...placement}>
<SWViewerSection surface="plain" aria-label="레이어 목록">
<div className="sw-viewer-story-toolbar">
<SWViewerInput type="search" placeholder="레이어 검색" aria-label="레이어 검색" value={query} onChange={event => setQuery(event.currentTarget.value)} />
<SWViewerButton onClick={() => set([])}>전체 켜기</SWViewerButton>
<SWViewerButton onClick={() => set(LAYERS.map(layer => layer.name))}>전체 끄기</SWViewerButton>
<SWViewerButton tone="green" onClick={() => set([])}>전체 켜기</SWViewerButton>
<SWViewerButton tone="green" onClick={() => set(LAYERS.map(layer => layer.name))}>전체 끄기</SWViewerButton>
</div>
{filtered.length === 0 ? <SWViewerHint>검색 결과가 없습니다 ("{query}")</SWViewerHint> : (
<ul className="sw-viewer-story-list">
@@ -152,7 +152,7 @@ export function OptionsPanel({ demo, footer, ...placement }: { demo: ViewerDemo;
const { options, setOptions } = demo
const set = (patch: Partial<ViewerOptions>) => setOptions({ ...options, ...patch })
return (
<SWViewerPanel title="옵션" icon={<SlidersHorizontal />} open={demo.panels.options} onClose={() => demo.toggle("options", false)} width={340} height={520} {...placement}
<SWViewerPanel title="옵션" open={demo.panels.options} onClose={() => demo.toggle("options", false)} width={340} height={520} {...placement}
headerActions={<SWViewerButton variant="ghost" aria-label="기본값으로" title="기본값으로" onClick={() => { setOptions(DEFAULT_OPTIONS); demo.record("options:reset") }}><RotateCcw size={16} /></SWViewerButton>}
footer={footer ? <><SWViewerButton onClick={() => demo.toggle("options", false)}>취소</SWViewerButton><SWViewerButton variant="primary" onClick={() => { demo.record("options:apply"); demo.toggle("options", false) }}>적용</SWViewerButton></> : undefined}>
<SWViewerSection title="선가중치">
@@ -182,7 +182,7 @@ export function PropertiesPanel({ demo, selected = true, ...placement }: { demo:
<dl className="sw-viewer-story-props">{items.map(([key, value]) => <div key={key}><dt>{key}</dt><dd title={value}>{value}</dd></div>)}</dl>
)
return (
<SWViewerPanel title="속성" icon={<Info />} open={demo.panels.properties} onClose={() => demo.toggle("properties", false)} {...placement}
<SWViewerPanel title="속성" open={demo.panels.properties} onClose={() => demo.toggle("properties", false)} {...placement}
headerActions={selected ? <SWViewerButton variant="ghost" aria-label="선택 확대" title="선택 확대" onClick={() => demo.record("zoomToSelection (미연결)")}><ZoomIn size={16} /></SWViewerButton> : undefined}>
{selected ? <>
<SWViewerSection surface="plain" title="일반 정보 (General)"><SWViewerBadge>LINE · h=2F1A</SWViewerBadge>{rows(ENTITY_ROWS)}</SWViewerSection>
+1 -4
View File
@@ -5,10 +5,7 @@
position: relative;
flex: 1;
min-height: 0;
background:
linear-gradient(rgba(38, 102, 81, 0.18) 1px, transparent 1px) 0 0 / 40px 40px,
linear-gradient(90deg, rgba(38, 102, 81, 0.18) 1px, transparent 1px) 0 0 / 40px 40px,
#0a0a0b;
background: var(--sw-story-canvas);
}
.sw-viewer-story-room { padding-bottom: 320px; }
.sw-viewer-story-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 12px 4px; }
+2 -2
View File
@@ -5,7 +5,7 @@ import {
type SWViewerHeaderGroup, type SWViewerMenuEntry,
} from "@hm-design/sw"
import "@hm-design/sw/styles.css"
import { Copy, Download, FileText, FolderOpen, Layers, Printer, Ruler, Scan, Search, Settings2 } from "lucide-react"
import { Copy, Download, FileText, FolderOpen, Layers, Printer, Ruler, Scan, Search } from "lucide-react"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { LAYERS, LayerPanel, OptionsPanel, PropertiesPanel, SAMPLES, useViewerDemo, type ViewerDemo } from "./sw-viewer-fixture"
import "./sw-stories.css"
@@ -265,7 +265,7 @@ export const ContentVariants: Story = {
<ViewerFrame height={640} header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<LayerPanel demo={demo} strategy="absolute" defaultPosition={{ left: 24, top: 16 }} />
<PropertiesPanel demo={demo} strategy="absolute" defaultPosition={{ left: 368, top: 16 }} />
<SWViewerPanel strategy="absolute" defaultPosition={{ left: 712, top: 16 }} title="축척 지정" icon={<Settings2 />} open={inputOpen} onClose={() => setInputOpen(false)} width={300} height="auto" resizable={false}
<SWViewerPanel strategy="absolute" defaultPosition={{ left: 712, top: 16 }} title="축척 지정" open={inputOpen} onClose={() => setInputOpen(false)} width={300} height="auto" resizable={false}
footer={<><SWViewerButton onClick={() => setInputOpen(false)}>취소</SWViewerButton><SWViewerButton variant="primary" onClick={() => { demo.record(`scale=${scale}, snap=${snap}`); setInputOpen(false) }}>적용</SWViewerButton></>}>
<SWViewerSection title="입력">
<SWViewerField label="축척">{id => <SWViewerInput id={id} value={scale} onChange={event => setScale(event.currentTarget.value)} style={{ width: 120 }} />}</SWViewerField>