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
No files matched your search
@@ -110,3 +110,16 @@
|
|||||||
## E. 최종 판정
|
## E. 최종 판정
|
||||||
|
|
||||||
**PARTIAL**: 패키지 컴포넌트 구현, Storybook, AI 지침, 주요 UI·인터랙션 검증은 끝났습니다. PASS가 아닌 이유는 요청 원문의 “SW 프로그램 뷰어 모드 통합”이 이번 범위에서 제외되어(사용자 결정) 진행하지 않았기 때문입니다. Commit·Push는 승인 대기입니다.
|
**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.
|
||||||
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,234 +3,633 @@
|
|||||||
"next": "http://127.0.0.1:6034",
|
"next": "http://127.0.0.1:6034",
|
||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"id": "overview",
|
"id": "sw-header--overview",
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
"size": "1920x1000 / 1920x1000",
|
"elements": "1 / 1",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
"result": "PASS"
|
667
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 716941,
|
||||||
|
"result": "FAIL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "overview",
|
"id": "sw-header--overview",
|
||||||
"width": 1180,
|
"width": 1180,
|
||||||
"size": "1180x1000 / 1180x1000",
|
"elements": "1 / 1",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 606746,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "overview",
|
"id": "sw-header--overview",
|
||||||
"width": 768,
|
"width": 768,
|
||||||
"size": "768x1000 / 768x1000",
|
"elements": "1 / 1",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 237853,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "interactive-states",
|
"id": "sw-header--interactive-states",
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
"size": "1920x2517 / 1920x2517",
|
"elements": "16 / 16",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "interactive-states",
|
"id": "sw-header--interactive-states",
|
||||||
"width": 1180,
|
"width": 1180,
|
||||||
"size": "1180x2527 / 1180x2527",
|
"elements": "16 / 16",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "interactive-states",
|
"id": "sw-header--interactive-states",
|
||||||
"width": 768,
|
"width": 768,
|
||||||
"size": "768x2623 / 768x2623",
|
"elements": "16 / 16",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "logo-slot",
|
"id": "sw-header--logo-slot",
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
"size": "1920x1000 / 1920x1000",
|
"elements": "4 / 4",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "logo-slot",
|
"id": "sw-header--logo-slot",
|
||||||
"width": 1180,
|
"width": 1180,
|
||||||
"size": "1180x1000 / 1180x1000",
|
"elements": "4 / 4",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "logo-slot",
|
"id": "sw-header--logo-slot",
|
||||||
"width": 768,
|
"width": 768,
|
||||||
"size": "768x1000 / 768x1000",
|
"elements": "4 / 4",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "document-dropdown",
|
"id": "sw-header--document-dropdown",
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
"size": "1920x9112 / 1920x9112",
|
"elements": "18 / 18",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 6111124,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "document-dropdown",
|
"id": "sw-header--document-dropdown",
|
||||||
"width": 1180,
|
"width": 1180,
|
||||||
"size": "1180x9122 / 1180x9122",
|
"elements": "18 / 18",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
"result": "PASS"
|
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,
|
"width": 768,
|
||||||
"size": "768x9298 / 768x9298",
|
"elements": "18 / 18",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
"result": "PASS"
|
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,
|
"width": 1920,
|
||||||
"size": "1920x10042 / 1920x10042",
|
"elements": "19 / 19",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 8338418,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "responsive-more",
|
"id": "sw-header--responsive-more",
|
||||||
"width": 1180,
|
"width": 1180,
|
||||||
"size": "1180x10052 / 1180x10052",
|
"elements": "19 / 19",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
"result": "PASS"
|
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,
|
"width": 768,
|
||||||
"size": "768x10048 / 768x10048",
|
"elements": "19 / 19",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
"result": "PASS"
|
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,
|
"width": 1920,
|
||||||
"size": "1920x1037 / 1920x1037",
|
"elements": "1 / 1",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "k-guide",
|
"id": "sw-header--k-guide",
|
||||||
"width": 1180,
|
"width": 1180,
|
||||||
"size": "1180x1028 / 1180x1028",
|
"elements": "1 / 1",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "k-guide",
|
"id": "sw-header--k-guide",
|
||||||
"width": 768,
|
"width": 768,
|
||||||
"size": "768x1023 / 768x1023",
|
"elements": "1 / 1",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "property-panel-compare",
|
"id": "sw-header--property-panel-compare",
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
"size": "1920x1000 / 1920x1000",
|
"elements": "4 / 4",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "property-panel-compare",
|
"id": "sw-header--property-panel-compare",
|
||||||
"width": 1180,
|
"width": 1180,
|
||||||
"size": "1180x1000 / 1180x1000",
|
"elements": "4 / 4",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "property-panel-compare",
|
"id": "sw-header--property-panel-compare",
|
||||||
"width": 768,
|
"width": 768,
|
||||||
"size": "768x1000 / 768x1000",
|
"elements": "4 / 4",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "composed-controls",
|
"id": "sw-header--composed-controls",
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
"size": "1920x1000 / 1920x1000",
|
"elements": "0 / 0",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "composed-controls",
|
"id": "sw-header--composed-controls",
|
||||||
"width": 1180,
|
"width": 1180,
|
||||||
"size": "1180x1000 / 1180x1000",
|
"elements": "0 / 0",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "composed-controls",
|
"id": "sw-header--composed-controls",
|
||||||
"width": 768,
|
"width": 768,
|
||||||
"size": "768x1000 / 768x1000",
|
"elements": "0 / 0",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "document-open",
|
"id": "sw-header--document-open",
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
"size": "1920x10771 / 1920x10771",
|
"elements": "23 / 23",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
"result": "PASS"
|
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,
|
"width": 1180,
|
||||||
"size": "1180x10761 / 1180x10761",
|
"elements": "23 / 23",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
"result": "PASS"
|
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,
|
"width": 768,
|
||||||
"size": "768x10797 / 768x10797",
|
"elements": "23 / 23",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
"result": "PASS"
|
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,
|
"width": 1920,
|
||||||
"size": "1920x1710 / 1920x1710",
|
"elements": "3 / 3",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
"result": "PASS"
|
0,
|
||||||
|
1199,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 1178934,
|
||||||
|
"result": "FAIL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "hidden-sub-controls",
|
"id": "sw-header--hidden-sub-controls",
|
||||||
"width": 1180,
|
"width": 1180,
|
||||||
"size": "1180x1700 / 1180x1700",
|
"elements": "3 / 3",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
"result": "PASS"
|
0,
|
||||||
|
1110,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 1084137,
|
||||||
|
"result": "FAIL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "hidden-sub-controls",
|
"id": "sw-header--hidden-sub-controls",
|
||||||
"width": 768,
|
"width": 768,
|
||||||
"size": "768x1676 / 768x1676",
|
"elements": "3 / 3",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [
|
||||||
"result": "PASS"
|
0,
|
||||||
|
722,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"fullPageDiffPixels": 676260,
|
||||||
|
"result": "FAIL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "detail-buttons",
|
"id": "sw-header--detail-buttons",
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
"size": "1920x1000 / 1920x1000",
|
"elements": "0 / 0",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "detail-buttons",
|
"id": "sw-header--detail-buttons",
|
||||||
"width": 1180,
|
"width": 1180,
|
||||||
"size": "1180x1000 / 1180x1000",
|
"elements": "0 / 0",
|
||||||
"diffPixels": 0,
|
"elementDiffPixels": [],
|
||||||
|
"fullPageDiffPixels": 0,
|
||||||
"result": "PASS"
|
"result": "PASS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "detail-buttons",
|
"id": "sw-header--detail-buttons",
|
||||||
"width": 768,
|
"width": 768,
|
||||||
"size": "768x1000 / 768x1000",
|
"elements": "0 / 0",
|
||||||
"diffPixels": 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"
|
"result": "PASS"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -374,7 +374,7 @@
|
|||||||
{
|
{
|
||||||
"name": "corner resize changes size",
|
"name": "corner resize changes size",
|
||||||
"result": "PASS",
|
"result": "PASS",
|
||||||
"detail": "320x420 → 263x323"
|
"detail": "314x420 → 257x323"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "drag moves panel",
|
"name": "drag moves panel",
|
||||||
@@ -396,13 +396,18 @@
|
|||||||
"result": "PASS",
|
"result": "PASS",
|
||||||
"detail": ""
|
"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",
|
"name": "panel content callback",
|
||||||
"result": "PASS",
|
"result": "PASS",
|
||||||
"detail": ""
|
"detail": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "close hover #E40000 / white X",
|
"name": "close hover = SWPopup hover asset",
|
||||||
"result": "PASS",
|
"result": "PASS",
|
||||||
"detail": ""
|
"detail": ""
|
||||||
},
|
},
|
||||||
@@ -414,7 +419,12 @@
|
|||||||
{
|
{
|
||||||
"name": "last touched panel on top",
|
"name": "last touched panel on top",
|
||||||
"result": "PASS",
|
"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",
|
"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"
|
"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",
|
"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",
|
"name": "fill / size / weight / spacing kept",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 94 KiB |
@@ -39,7 +39,7 @@
|
|||||||
- **반응형(참고 뷰어와 동일):** 항목별 `collapseBelow`로 More(⋯, 베이지 260px Dropdown)로 이동합니다. 참고 뷰어 기준값: 샘플·도면 공간·2.5D·어두운 배경 `1180`, 패널 토글 `900`, 파일 열기·전체 보기 `iconOnlyBelow: 560`. 1180px 미만은 간격 2px, 560px 미만은 상태 글자 숨김(CSS container). 가로 스크롤은 만들지 않습니다.
|
- **반응형(참고 뷰어와 동일):** 항목별 `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는 라디오 항목이 됩니다.
|
- More는 그룹/항목 단위 구획으로 자동 구성됩니다: menu·tabs는 자기 구획(`moreLabel`), button·toggle·switch는 그룹 구획(`group.moreLabel ?? group.label`). toggle·switch는 체크 항목, tabs는 라디오 항목이 됩니다.
|
||||||
- **상태 표현:** Hover = #266651 면, Active·Pressed·열림·선택 탭 = 글자·아이콘 #FFC600(선택 박스 없음), Disabled = 60% 불투명, Focus = 2px #FFC600 외곽선.
|
- **상태 표현:** 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`
|
## 3. `SWViewerMenu` / `SWViewerMenuEntry`
|
||||||
|
|
||||||
@@ -50,15 +50,15 @@
|
|||||||
|
|
||||||
## 4. `SWViewerPanel` — 팝업 프레임 + 자유 콘텐츠
|
## 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 | 설명 |
|
| 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`, 프로그램 목록·표 등 무엇이든 |
|
| `children` | 스크롤 본문. `SWViewerSection`(카드/plain, 12px 제목), `SWViewerField`(라벨 + 컨트롤, `inline` / `stacked` + `below`), `SWViewerSwitch`, `SWViewerSlider`, `SWViewerInput`, `SWViewerButton`, `SWViewerBadge`, `SWViewerHint`, 프로그램 목록·표 등 무엇이든 |
|
||||||
| `footer?` | 하단 버튼 영역(본문 스크롤과 무관하게 고정). `SWViewerButton variant="outline" \| "primary"` |
|
| `footer?` | 하단 버튼 영역(본문 스크롤과 무관하게 고정). `SWViewerButton variant="outline" \| "primary"`. `tone="green"` = 기본은 그대로, Hover는 primary(확인) 버튼 Hover와 같은 토큰 `--hm-sw-viewer-hover`, 누르는 동안 primary 면 `--hm-sw-viewer-panel-primary` (예: 레이어 전체 켜기 / 전체 끄기) |
|
||||||
| `headerActions?` | 제목 막대 추가 버튼: `SWViewerButton variant="ghost"` (기본값 복원, 선택 확대 등) |
|
| `headerActions?` | 제목 막대 추가 버튼: `SWViewerButton variant="ghost"` (26px, 글자색만 Hover #FFC600 — SW 제목 막대 방식) |
|
||||||
| `width?`(320), `height?`(420, `"auto"` 가능), `minWidth?`(250), `minHeight?`(140) | 메뉴별 크기. 최대 90vw × 90vh |
|
| `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 아래) |
|
| `defaultPosition?` | `{ left, top }` 또는 `"top-end"`(기본) / `"top-start"` / `"center"`(modal 기본), `inset?: { top?, side? }`(기본 56 / 24 = 52px Header 아래) |
|
||||||
| `strategy?` | `fixed`(기본, 화면) / `absolute`(가장 가까운 positioned 조상 안 — 미리보기·임베드) |
|
| `strategy?` | `fixed`(기본, 화면) / `absolute`(가장 가까운 positioned 조상 안 — 미리보기·임베드) |
|
||||||
| `draggable?`, `resizable?`, `collapsible?`, `defaultCollapsed?`, `collapsed?`, `onCollapsedChange?`, `onPositionChange?`, `onSizeChange?` | 제목 막대 드래그, 오른쪽 아래 모서리 크기 조절, 접기(버튼·제목 더블클릭). 이동·크기는 경계 10px 안 |
|
| `draggable?`, `resizable?`, `collapsible?`, `defaultCollapsed?`, `collapsed?`, `onCollapsedChange?`, `onPositionChange?`, `onSizeChange?` | 제목 막대 드래그, 오른쪽 아래 모서리 크기 조절, 접기(버튼·제목 더블클릭). 이동·크기는 경계 10px 안 |
|
||||||
@@ -101,4 +101,5 @@ const groups: SWViewerHeaderGroup[] = [
|
|||||||
## 7. 디자인 변경 시 주의
|
## 7. 디자인 변경 시 주의
|
||||||
|
|
||||||
- 크기(52px Header, 34px 버튼, 40px 토글·탭·스위치 칸, 41~42px field 면, 72px 로고 면)와 색은 참고 뷰어 실측값입니다. 파일명·객체 수정·속성 면 배경은 SWHeader와 같은 사용자 확정 토큰(`--hm-sw-header-document-*`, `object-*`, `property-*`)을 공유하므로 바꾸면 두 Header가 함께 바뀝니다.
|
- 크기(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 닫기 추가, 로고 기본 표시 없음(사용자 결정).
|
- 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)} />
|
<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="레이어 목록">
|
<SWViewerSection surface="plain" aria-label="레이어 목록">
|
||||||
<SWViewerHint>프로그램의 레이어 목록을 여기에 둡니다.</SWViewerHint>
|
<SWViewerHint>프로그램의 레이어 목록을 여기에 둡니다.</SWViewerHint>
|
||||||
</SWViewerSection>
|
</SWViewerSection>
|
||||||
</SWViewerPanel>
|
</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>}>
|
footer={<SWViewerButton variant="primary" onClick={() => { onEvent?.("options:apply"); setPanels({ ...panels, options: false }) }}>적용</SWViewerButton>}>
|
||||||
<SWViewerSection title="표시">
|
<SWViewerSection title="표시">
|
||||||
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={grid} onCheckedChange={setGrid} />}</SWViewerField>
|
<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
|
autoFocus?: boolean
|
||||||
/** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */
|
/** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */
|
||||||
anchorRef?: RefObject<HTMLElement | null>
|
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
|
* 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.
|
* when it is shown and where (`style`, or `anchorRef`). Wrap it in <SWTheme> outside SWHeader.
|
||||||
*/
|
*/
|
||||||
export function SWPopup({ closeLabel = "닫기", ...props }: SWPopupProps) {
|
export function SWPopup({ closeLabel = "닫기", footer, ...props }: SWPopupProps) {
|
||||||
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" {...props} />
|
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" after={footer} {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SWPopupSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
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 { forwardRef, useId, type ButtonHTMLAttributes, type HTMLAttributes, type InputHTMLAttributes, type ReactNode } from "react"
|
||||||
|
import { SWViewerIcon } from "./SWViewerIcon"
|
||||||
import "../../theme/sw-viewer.css"
|
import "../../theme/sw-viewer.css"
|
||||||
|
|
||||||
const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(" ")
|
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) {
|
export function SWViewerStatus({ state, label, detail, className, ...rest }: SWViewerStatusProps) {
|
||||||
return (
|
return (
|
||||||
<div role="status" className={cx("hm-sw-viewer-status", className)} data-state={state} title={detail} {...rest}>
|
<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>
|
<span className="hm-sw-viewer-status__label">{label ?? statusLabel[state]}</span>
|
||||||
{detail && <span className="hm-sw-viewer-sr-only">: {detail}</span>}
|
{detail && <span className="hm-sw-viewer-sr-only">: {detail}</span>}
|
||||||
</div>
|
</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.
|
* `primary`: the same size filled with #123328 (footer confirm). `ghost`: icon button of the panel title bar.
|
||||||
*/
|
*/
|
||||||
variant?: "outline" | "primary" | "ghost"
|
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
|
icon?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SWViewerButton = forwardRef<HTMLButtonElement, SWViewerButtonProps>(function SWViewerButton(
|
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 (
|
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>}
|
{icon && <span className="hm-sw-viewer-button__icon" aria-hidden="true">{icon}</span>}
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
|
import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
|
||||||
import { SWViewerMenu } from "./SWViewerMenu"
|
import { SWViewerMenu } from "./SWViewerMenu"
|
||||||
import { SWViewerSwitch } from "./SWViewerControls"
|
import { SWViewerSwitch } from "./SWViewerControls"
|
||||||
|
import { SWViewerIcon } from "./SWViewerIcon"
|
||||||
import type { SWViewerCommand, SWViewerHeaderGroup, SWViewerHeaderItem, SWViewerMenuEntry } from "./types"
|
import type { SWViewerCommand, SWViewerHeaderGroup, SWViewerHeaderItem, SWViewerMenuEntry } from "./types"
|
||||||
import "../../theme/sw-viewer.css"
|
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}
|
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"))}
|
onClick={() => (open ? setOpen(false) : show("menu"))}
|
||||||
onKeyDown={event => { if (event.key === "ArrowDown" || ((event.key === "Enter" || event.key === " ") && !open)) { event.preventDefault(); show("first") } }}>
|
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>}
|
{!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>
|
</button>
|
||||||
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} label={item.label} initialFocus={focus}
|
<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 })} />
|
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 (
|
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}
|
<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}>
|
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>}
|
{!iconOnly && <span className="hm-sw-viewer-header__label">{label}</span>}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -272,7 +273,7 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
|
|||||||
aria-label={moreLabel} title="더보기" aria-haspopup="menu" aria-expanded={moreOpen}
|
aria-label={moreLabel} title="더보기" aria-haspopup="menu" aria-expanded={moreOpen}
|
||||||
onClick={() => { setMoreFocus("menu"); setMoreOpen(!moreOpen) }}
|
onClick={() => { setMoreFocus("menu"); setMoreOpen(!moreOpen) }}
|
||||||
onKeyDown={event => { if (event.key === "ArrowDown") { event.preventDefault(); setMoreFocus("first"); setMoreOpen(true) } }}>
|
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>
|
</button>
|
||||||
<SWViewerMenu entries={more} open={moreOpen} onOpenChange={setMoreOpen} anchorRef={moreRef} align="end" width={260} surface="beige" label={moreLabel} initialFocus={moreFocus}
|
<SWViewerMenu entries={more} open={moreOpen} onOpenChange={setMoreOpen} anchorRef={moreRef} align="end" width={260} surface="beige" label={moreLabel} initialFocus={moreFocus}
|
||||||
onEntrySelect={entry => {
|
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"
|
import "../../theme/sw-viewer.css"
|
||||||
|
|
||||||
export type SWViewerPanelPlacement = "top-end" | "top-start" | "center"
|
export type SWViewerPanelPlacement = "top-end" | "top-start" | "center"
|
||||||
|
|
||||||
export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
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
|
title: string
|
||||||
/** 16px title icon supplied by the program. */
|
|
||||||
icon?: ReactNode
|
|
||||||
/** Shown when true. Unmounted otherwise (no hidden DOM). */
|
/** Shown when true. Unmounted otherwise (no hidden DOM). */
|
||||||
open: boolean
|
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
|
onClose: () => void
|
||||||
closeLabel?: string
|
closeLabel?: string
|
||||||
/** Extra title-bar buttons before collapse / close (use `SWViewerButton variant="ghost"`). */
|
/** Extra title-bar buttons before collapse / close (use `SWViewerButton variant="ghost"`). */
|
||||||
@@ -19,7 +18,7 @@ export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>,
|
|||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
/** Bottom button area (e.g. 취소 / 적용). Stays visible while the body scrolls. */
|
/** Bottom button area (e.g. 취소 / 적용). Stays visible while the body scrolls. */
|
||||||
footer?: ReactNode
|
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
|
width?: number
|
||||||
height?: number | "auto"
|
height?: number | "auto"
|
||||||
minWidth?: number
|
minWidth?: number
|
||||||
@@ -48,7 +47,7 @@ export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>,
|
|||||||
onSizeChange?: (size: { width: number; height: number }) => void
|
onSizeChange?: (size: { width: number; height: number }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let topLayer = 30
|
let topLayer = 40
|
||||||
const EDGE = 10
|
const EDGE = 10
|
||||||
const INTERACTIVE = "button, input, select, textarea, a, label, [data-panel-no-drag]"
|
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>
|
<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") {
|
function bounds(element: HTMLElement, strategy: "fixed" | "absolute") {
|
||||||
const parent = strategy === "absolute" ? element?.offsetParent as HTMLElement | null : null
|
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 }
|
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,
|
* SW viewer popup / settings window. The frame is the common SW popup (`SWPopup`: title bar, title, close,
|
||||||
* #221D13 title bar with #FFC600 title, collapse and the curved green close (#E40000 on hover). Non-modal by
|
* border, radius, shadow — the same component and DOM as every SW popup); this adds the viewer window behavior:
|
||||||
* default: drag by the title bar, corner resize, collapse, stays inside the bounds, Escape inside closes, the
|
* drag by the title bar, corner resize, collapse, placement inside the bounds, bring to front, optional modal.
|
||||||
* last touched panel comes to the front. The frame is fixed; `children` / `footer` are free.
|
* `children` / `footer` are free content.
|
||||||
*/
|
*/
|
||||||
export function SWViewerPanel({
|
export function SWViewerPanel({
|
||||||
title, icon, open, onClose, closeLabel = "닫기", headerActions, children, footer,
|
title, open, onClose, closeLabel = "닫기", headerActions, children, footer,
|
||||||
width = 320, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed",
|
width = 314, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed",
|
||||||
draggable = true, resizable = true, collapsible = true, defaultCollapsed = false, collapsed: collapsedProp, onCollapsedChange,
|
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) {
|
}: SWViewerPanelProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const generatedId = useId()
|
||||||
const titleId = useId()
|
const id = idProp ?? `hm-sw-viewer-panel-${generatedId.replace(/:/g, "")}`
|
||||||
const closeGradient = `hm-sw-viewer-close-${useId().replace(/:/g, "")}`
|
const element = () => document.getElementById(id) as HTMLDivElement | null
|
||||||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
||||||
const [layer, setLayer] = useState(() => ++topLayer)
|
const [layer, setLayer] = useState(() => ++topLayer)
|
||||||
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed)
|
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed)
|
||||||
@@ -96,10 +96,10 @@ export function SWViewerPanel({
|
|||||||
positionRef.current = position
|
positionRef.current = position
|
||||||
const placement = defaultPosition ?? (modal ? "center" : "top-end")
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) { setPosition(null); setSize(null); return }
|
if (!open) { setPosition(null); setSize(null); return }
|
||||||
const panel = ref.current
|
const panel = element()
|
||||||
if (!panel) return
|
if (!panel) return
|
||||||
const box = bounds(panel, strategy)
|
const box = bounds(panel, strategy)
|
||||||
const top = inset?.top ?? 56, side = inset?.side ?? 24
|
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 }
|
: 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 }
|
: { left: placement === "top-end" ? box.width - panel.offsetWidth - side : side, top }
|
||||||
setPosition(clamp(start.left, start.top, panel, strategy))
|
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)
|
window.addEventListener("resize", keepInside)
|
||||||
const parent = strategy === "absolute" ? panel.offsetParent : null
|
const parent = strategy === "absolute" ? panel.offsetParent : null
|
||||||
const observer = parent ? new ResizeObserver(keepInside) : null
|
const observer = parent ? new ResizeObserver(keepInside) : null
|
||||||
@@ -115,46 +115,52 @@ export function SWViewerPanel({
|
|||||||
return () => { window.removeEventListener("resize", keepInside); observer?.disconnect() }
|
return () => { window.removeEventListener("resize", keepInside); observer?.disconnect() }
|
||||||
}, [open])
|
}, [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
|
if (!open) return null
|
||||||
const raise = () => { if (layer !== topLayer) setLayer(++topLayer) }
|
const raise = () => { if (layer !== topLayer) setLayer(++topLayer) }
|
||||||
const setCollapsed = (next: boolean) => { if (collapsedProp === undefined) setInternalCollapsed(next); onCollapsedChange?.(next) }
|
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>) => {
|
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest(INTERACTIVE) || !ref.current || !position) return
|
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 }
|
drag.current = { x: event.clientX, y: event.clientY, left: position.left, top: position.top }
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
event.currentTarget.dataset.dragging = ""
|
event.currentTarget.dataset.dragging = ""
|
||||||
}
|
}
|
||||||
const onTitlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
const moveDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
const start = drag.current
|
onPointerMove?.(event)
|
||||||
if (!start || !ref.current) return
|
const start = drag.current, panel = element()
|
||||||
setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, ref.current, strategy))
|
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
|
if (!drag.current) return
|
||||||
drag.current = null
|
drag.current = null
|
||||||
delete event.currentTarget.dataset.dragging
|
delete event.currentTarget.dataset.dragging
|
||||||
if (positionRef.current) onPositionChange?.(positionRef.current)
|
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>) => {
|
const onGripPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
if (event.button !== 0 || !ref.current) return
|
const panel = element()
|
||||||
|
if (event.button !== 0 || !panel) return
|
||||||
event.preventDefault()
|
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)
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
}
|
}
|
||||||
const onGripPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
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
|
if (!start || !panel || !position) return
|
||||||
const box = bounds(panel, strategy)
|
const box = bounds(panel, strategy)
|
||||||
// Min size, 90% of the bounds, and never past the bounds edge (10px) from the current corner.
|
// 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))),
|
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
|
if (!resize.current) return
|
||||||
resize.current = null
|
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)
|
onKeyDown?.(event)
|
||||||
if (event.defaultPrevented || event.nativeEvent.isComposing) return
|
const panel = element()
|
||||||
if (event.key === "Escape") { event.stopPropagation(); onClose(); return }
|
if (!modal || event.key !== "Tab" || event.defaultPrevented || !panel) return
|
||||||
if (modal && event.key === "Tab" && ref.current) {
|
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"])'))
|
||||||
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 }
|
if (!focusables.length) { event.preventDefault(); return }
|
||||||
const first = focusables[0], last = focusables[focusables.length - 1]
|
const first = focusables[0], last = focusables[focusables.length - 1]
|
||||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) { event.preventDefault(); last.focus() }
|
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) { event.preventDefault(); last.focus() }
|
||||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
const currentWidth = size?.width ?? width
|
||||||
const frameStyle: CSSProperties = {
|
const frameStyle: CSSProperties = {
|
||||||
position: strategy,
|
position: strategy,
|
||||||
left: position?.left ?? 0,
|
left: position?.left ?? 0,
|
||||||
top: position?.top ?? 0,
|
top: position?.top ?? 0,
|
||||||
zIndex: layer,
|
zIndex: layer,
|
||||||
width: size?.width ?? width,
|
width: currentWidth,
|
||||||
height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height,
|
height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height,
|
||||||
minWidth,
|
minWidth,
|
||||||
minHeight: collapsed ? undefined : Math.min(minHeight, typeof height === "number" ? height : minHeight),
|
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,
|
...style,
|
||||||
}
|
}
|
||||||
const panel = (
|
const panel = (
|
||||||
<div ref={ref} role="dialog" aria-modal={modal} aria-labelledby={titleId} tabIndex={-1}
|
<SWPopup {...rest} id={id} title={title} onClose={onClose} closeLabel={closeLabel} autoFocus={autoFocus}
|
||||||
className={`hm-sw-theme hm-sw-viewer-panel${className ? ` ${className}` : ""}`} data-collapsed={collapsed || undefined} data-modal={modal || undefined}
|
className={`hm-sw-theme hm-sw-viewer-panel${className ? ` ${className}` : ""}`} style={frameStyle}
|
||||||
style={frameStyle} {...rest}
|
aria-modal={modal}
|
||||||
onKeyDown={onPanelKeyDown}
|
data-collapsed={collapsed || undefined} data-modal={modal || undefined} data-draggable={draggable || undefined}
|
||||||
|
onKeyDown={trapFocus}
|
||||||
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
|
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
|
||||||
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}>
|
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}
|
||||||
<div className="hm-sw-viewer-panel__titlebar" data-draggable={draggable || undefined}
|
onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={endDrag} onDoubleClick={toggleByTitle}
|
||||||
onPointerDown={onTitlePointerDown} onPointerMove={onTitlePointerMove} onPointerUp={onTitlePointerUp} onPointerCancel={onTitlePointerUp}
|
titleActions={(headerActions || collapsible) && <div className="hm-sw-viewer-panel__actions">
|
||||||
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}
|
{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>}
|
{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}>
|
</div>}
|
||||||
<svg className="hm-sw-viewer-panel__close-shape" viewBox="0 0 52 34" preserveAspectRatio="none" aria-hidden="true" focusable="false">
|
footer={<>
|
||||||
<defs>
|
{footer && !collapsed && <div className="hm-sw-viewer-panel__footer">{footer}</div>}
|
||||||
<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=""
|
{resizable && !collapsed && <div className="hm-sw-viewer-panel__resize" aria-hidden="true" data-panel-no-drag=""
|
||||||
onPointerDown={onGripPointerDown} onPointerMove={onGripPointerMove} onPointerUp={onGripPointerUp} onPointerCancel={onGripPointerUp} />}
|
onPointerDown={onGripPointerDown} onPointerMove={onGripPointerMove} onPointerUp={onGripPointerUp} onPointerCancel={onGripPointerUp} />}
|
||||||
</div>
|
</>}>
|
||||||
|
{collapsed ? null : children}
|
||||||
|
</SWPopup>
|
||||||
)
|
)
|
||||||
return modal ? <><div className="hm-sw-viewer-panel-backdrop" style={{ position: strategy, zIndex: layer }} aria-hidden="true" />{panel}</> : panel
|
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 { SWViewerHeader, SWViewerHeaderButton, type SWViewerHeaderProps, type SWViewerHeaderButtonProps } from "./SWViewerHeader"
|
||||||
export { SWViewerMenu, type SWViewerMenuProps } from "./SWViewerMenu"
|
export { SWViewerMenu, type SWViewerMenuProps } from "./SWViewerMenu"
|
||||||
|
export { SWViewerIcon, type SWViewerIconProps } from "./SWViewerIcon"
|
||||||
export { SWViewerPanel, type SWViewerPanelProps, type SWViewerPanelPlacement } from "./SWViewerPanel"
|
export { SWViewerPanel, type SWViewerPanelProps, type SWViewerPanelPlacement } from "./SWViewerPanel"
|
||||||
export {
|
export {
|
||||||
SWViewerSection, SWViewerField, SWViewerHint, SWViewerSwitch, SWViewerSlider, SWViewerInput, SWViewerButton, SWViewerBadge, SWViewerStatus,
|
SWViewerSection, SWViewerField, SWViewerHint, SWViewerSwitch, SWViewerSlider, SWViewerInput, SWViewerButton, SWViewerBadge, SWViewerStatus,
|
||||||
|
|||||||
@@ -29,15 +29,9 @@
|
|||||||
--hm-sw-viewer-switch-off: #0a221d;
|
--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);
|
--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
|
/* 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
|
(Figma mask "outside" path). SWViewerIcon draws it as SVG stroke on a copy behind the icon (see SWViewerIcon). */
|
||||||
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. */
|
|
||||||
--hm-sw-viewer-icon-outline-color: var(--hm-sw-header-text-outline-color);
|
--hm-sw-viewer-icon-outline-color: var(--hm-sw-header-text-outline-color);
|
||||||
--hm-sw-viewer-icon-glow: #061a14;
|
--hm-sw-viewer-icon-outline-width: 1px;
|
||||||
--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));
|
|
||||||
|
|
||||||
/* Dropdown (light) and More (beige) */
|
/* Dropdown (light) and More (beige) */
|
||||||
--hm-sw-viewer-menu-background: #fff;
|
--hm-sw-viewer-menu-background: #fff;
|
||||||
@@ -51,31 +45,20 @@
|
|||||||
--hm-sw-viewer-more-background: #f5f0e5;
|
--hm-sw-viewer-more-background: #f5f0e5;
|
||||||
--hm-sw-viewer-more-border-color: #897445;
|
--hm-sw-viewer-more-border-color: #897445;
|
||||||
|
|
||||||
/* Panel */
|
/* Panel: the frame is SWPopup (title bar, title, close, border, radius, shadow). Content tokens map to the
|
||||||
--hm-sw-viewer-panel-background: #e0dace;
|
SW property window tokens so viewer content sits on the same surface as every SW popup. */
|
||||||
--hm-sw-viewer-panel-field-background: #f5f0e5;
|
--hm-sw-viewer-panel-background: var(--hm-sw-property-surface);
|
||||||
--hm-sw-viewer-panel-border: #897445;
|
--hm-sw-viewer-panel-field-background: var(--hm-sw-property-content);
|
||||||
--hm-sw-viewer-panel-inner-border: #9a9284;
|
--hm-sw-viewer-panel-inner-border: var(--hm-sw-property-inner-border);
|
||||||
--hm-sw-viewer-panel-title-background: #221d13;
|
--hm-sw-viewer-panel-text: var(--hm-sw-property-label);
|
||||||
--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;
|
|
||||||
--hm-sw-viewer-panel-heading: #3e3523;
|
--hm-sw-viewer-panel-heading: #3e3523;
|
||||||
--hm-sw-viewer-panel-label: #6e6759;
|
--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-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-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);
|
--hm-sw-viewer-panel-overlay: rgba(0, 0, 0, 0.1);
|
||||||
/* Close: green curve, #E40000 on hover (user-supplied reference value). */
|
--hm-sw-viewer-panel-spacing: 12px;
|
||||||
--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-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
.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, .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%; }
|
.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. */
|
/* 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__tabs { display: flex; align-items: center; gap: var(--hm-sw-viewer-gap); }
|
||||||
.hm-sw-viewer-header .hm-sw-viewer-header__tab {
|
.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
|
/* 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.
|
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
|
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
|
weight and spacing are unchanged; hover / active / open change only the fill, disabled fades the whole button
|
||||||
(opacity) like SWHeader. Dropdown faces (light / beige) and panels get no outline, like SWHeader's More menu. */
|
(opacity) like SWHeader. Dropdown faces (light / beige) and panels get no outline, like SWHeader's More menu. */
|
||||||
.hm-sw-viewer-header :is(
|
.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) {
|
.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);
|
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. */
|
/* 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 {
|
.hm-sw-viewer-header .hm-sw-viewer-header__document-name {
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
@@ -264,7 +251,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
white-space: nowrap;
|
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[data-state="error"] { color: var(--hm-sw-viewer-accent); }
|
||||||
.hm-sw-viewer-status__spin { animation: hm-sw-viewer-spin 1s linear infinite; }
|
.hm-sw-viewer-status__spin { animation: hm-sw-viewer-spin 1s linear infinite; }
|
||||||
@keyframes hm-sw-viewer-spin { to { transform: rotate(360deg); } }
|
@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__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); }
|
.hm-sw-viewer-menu[data-surface="beige"] .hm-sw-viewer-menu__separator { background: var(--hm-sw-viewer-panel-inner-border); }
|
||||||
|
|
||||||
/* ---------- Panel ---------- */
|
/* ---------- Panel (SWPopup frame + viewer window behavior) ---------- */
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.hm-sw-viewer-panel-backdrop { inset: 0; background: var(--hm-sw-viewer-panel-overlay); }
|
.hm-sw-viewer-panel-backdrop { inset: 0; background: var(--hm-sw-viewer-panel-overlay); }
|
||||||
.hm-sw-viewer-panel__titlebar {
|
.hm-sw-viewer-panel[data-collapsed] .hm-sw-property__body { display: none; }
|
||||||
display: flex;
|
.hm-sw-viewer-panel[data-draggable] .hm-sw-property__titlebar { cursor: grab; touch-action: none; user-select: none; }
|
||||||
flex-shrink: 0;
|
.hm-sw-viewer-panel[data-dragging] .hm-sw-property__titlebar { cursor: grabbing; }
|
||||||
align-items: center;
|
/* Any width: the Figma title art keeps its 308px curve at the close button (right-aligned; at 314px this is exactly
|
||||||
gap: 4px;
|
the SWPopup position), and a stretched copy of the same art fills the flat part on the left of wider panels.
|
||||||
min-height: var(--hm-sw-viewer-panel-title-height);
|
The right one hides its own left 20px (left edge + corner) and the left copy ends exactly there: no seam, no
|
||||||
padding-left: var(--hm-sw-viewer-panel-spacing);
|
doubled shadow. */
|
||||||
border-bottom: 1px solid var(--hm-sw-viewer-panel-border);
|
.hm-sw-viewer-panel .hm-sw-property__title-art { left: auto; right: 4px; clip-path: inset(-4px -4px -4px 20px); }
|
||||||
background: var(--hm-sw-viewer-panel-title-background);
|
.hm-sw-viewer-panel .hm-sw-property__titlebar::before {
|
||||||
box-shadow: inset 0 1px 0 var(--hm-sw-viewer-panel-title-highlight);
|
content: "";
|
||||||
user-select: none;
|
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__actions { position: relative; z-index: 2; display: flex; flex-shrink: 0; align-items: center; height: 30px; }
|
||||||
.hm-sw-viewer-panel__titlebar[data-dragging] { cursor: grabbing; }
|
/* Title-bar buttons: SW text-only look (no face), #FFC600 on hover like the SW title-bar controls. */
|
||||||
.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 :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) {
|
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: var(--hm-sw-viewer-panel-title-height);
|
width: 26px;
|
||||||
height: var(--hm-sw-viewer-panel-title-height);
|
height: 26px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--hm-sw-viewer-muted);
|
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"]):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"], .hm-sw-viewer-panel__close):focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-title-color); outline-offset: -3px; }
|
.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 .hm-sw-viewer-panel__close {
|
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) svg { width: 16px; height: 16px; }
|
||||||
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__footer {
|
.hm-sw-viewer-panel__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px var(--hm-sw-viewer-panel-spacing);
|
padding: 7px;
|
||||||
border-top: 1px solid var(--hm-sw-viewer-panel-inner-border);
|
border-top: 1px solid var(--hm-sw-viewer-panel-inner-border);
|
||||||
background: var(--hm-sw-viewer-panel-background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Panel content */
|
/* 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="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; }
|
.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[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: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; }
|
.hm-sw-theme .hm-sw-viewer-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Regression for the SW viewer work (2026-10-19): the existing SW/Header Stories must render pixel-identical
|
// Regression for the SW viewer work (2026-10-19): the existing SW/Header and SW/Popup Stories must render their
|
||||||
// before (base Storybook) and after (this branch) at several widths.
|
// 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>
|
// Usage: node packages/sw/tests/viewer-regression.browser.mjs <base-storybook-url> <new-storybook-url> <out-dir>
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { mkdir, writeFile } from 'node:fs/promises'
|
import { mkdir, writeFile } from 'node:fs/promises'
|
||||||
@@ -10,38 +12,51 @@ const require = createRequire(path.join(runtime, 'index.js'))
|
|||||||
const { chromium } = require('playwright')
|
const { chromium } = require('playwright')
|
||||||
const { PNG } = require('pngjs')
|
const { PNG } = require('pngjs')
|
||||||
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
|
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')
|
const out = path.resolve(outArg ?? 'docs/sw/viewer-qa/regression')
|
||||||
await mkdir(out, { recursive: true })
|
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 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 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' })
|
const page = await browser.newPage({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' })
|
||||||
await page.goto(url)
|
await page.goto(url)
|
||||||
await page.waitForSelector('#storybook-root > *')
|
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.evaluate(() => document.fonts.ready)
|
||||||
await page.waitForTimeout(400)
|
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()
|
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 = []
|
const results = []
|
||||||
for (const id of ids) for (const width of widths) {
|
for (const id of ids) for (const width of widths) {
|
||||||
const url = root => `${root}/iframe.html?id=sw-header--${id}&viewMode=story`
|
const url = root => `${root}/iframe.html?id=${id}&viewMode=story`
|
||||||
const [a, b] = [await shot(url(base), width), await shot(url(next), width)]
|
const [a, b] = [await shot(url(base), width), await shot(url(next), width, neutral)]
|
||||||
const same = a.width === b.width && a.height === b.height
|
const elementDiffs = a.elements.length === b.elements.length ? a.elements.map((item, index) => compare(item, b.elements[index])) : [-1]
|
||||||
let diff = same ? 0 : -1
|
const fullDiff = compare(a.full, b.full)
|
||||||
if (same) {
|
const pass = elementDiffs.every(diff => diff === 0)
|
||||||
const image = new PNG({ width: a.width, height: a.height })
|
results.push({ id, width, elements: a.elements.length + ' / ' + b.elements.length, elementDiffPixels: elementDiffs, fullPageDiffPixels: fullDiff, result: pass ? 'PASS' : 'FAIL' })
|
||||||
diff = pixelmatch(a.data, b.data, image.data, a.width, a.height, { threshold: 0 })
|
console.log(`${pass ? 'PASS' : 'FAIL'} ${id} @${width} elements ${a.elements.length}/${b.elements.length} diff [${elementDiffs.join(',')}] full-page diff ${fullDiff}`)
|
||||||
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}`)
|
|
||||||
}
|
}
|
||||||
await browser.close()
|
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
|
const failed = results.filter(item => item.result !== 'PASS').length
|
||||||
console.log(`${results.length - failed}/${results.length} PASS`)
|
console.log(`${results.length - failed}/${results.length} PASS`)
|
||||||
process.exit(failed ? 1 : 0)
|
process.exit(failed ? 1 : 0)
|
||||||
@@ -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)}`)
|
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.
|
// Drag: title bar moves the panel and it stays inside the frame.
|
||||||
const before = await box(layerPanel)
|
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)
|
const titleBox = await box(title)
|
||||||
await page.mouse.move(titleBox.x + 40, titleBox.y + 15)
|
await page.mouse.move(titleBox.x + 40, titleBox.y + 15)
|
||||||
await page.mouse.down()
|
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))}`)
|
`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.
|
// Collapse by button and double click.
|
||||||
await layerPanel.getByRole('button', { name: '접기' }).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 } })
|
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).
|
// 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()
|
await layerPanel.getByRole('button', { name: 'CR-TEXT 숨기기' }).click()
|
||||||
check('panel content callback', (await log(page)).includes('layer:CR-TEXT=false'))
|
check('panel content callback', (await log(page)).includes('layer:CR-TEXT=false'))
|
||||||
// Close button hover face.
|
// Close button hover face.
|
||||||
const close = layerPanel.getByRole('button', { name: '닫기' })
|
const close = layerPanel.getByRole('button', { name: '닫기' })
|
||||||
await close.hover()
|
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()
|
await close.click()
|
||||||
check('close button closes', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
|
check('close button closes', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
|
||||||
// Two panels: the last touched comes to the front.
|
// Two panels: the last touched comes to the front.
|
||||||
@@ -192,12 +205,17 @@ if (comparison.reference) {
|
|||||||
await page.getByRole('button', { name: '설정', exact: true }).click()
|
await page.getByRole('button', { name: '설정', exact: true }).click()
|
||||||
const options = page.getByRole('dialog', { name: '옵션' })
|
const options = page.getByRole('dialog', { name: '옵션' })
|
||||||
const z = async () => Promise.all([layerPanel, options].map(item => item.evaluate(element => Number(getComputedStyle(element).zIndex))))
|
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()
|
const [zl, zo] = await z()
|
||||||
check('last touched panel on top', zl > zo, `layer z ${zl}, options z ${zo}`)
|
check('last touched panel on top', zl > zo, `layer z ${zl}, options z ${zo}`)
|
||||||
// Modal popup: backdrop, focus trap, Escape.
|
// Modal popup: backdrop, focus trap, Escape.
|
||||||
await page.getByRole('button', { name: '정보 및 라이선스' }).click()
|
await page.getByRole('button', { name: '정보 및 라이선스' }).click()
|
||||||
const about = page.getByRole('dialog', { name: /라이선스/ })
|
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)
|
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')
|
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)))
|
check('modal focus stays inside', await about.evaluate(element => element.contains(document.activeElement)))
|
||||||
@@ -343,7 +361,13 @@ if (comparison.reference) {
|
|||||||
return {
|
return {
|
||||||
label: style('.hm-sw-viewer-header__label').textShadow, document: style('.hm-sw-viewer-header__document-name').textShadow,
|
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,
|
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,
|
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(' '),
|
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
|
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('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('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)
|
check('fill / size / weight / spacing kept', outline.labelColor === 'rgb(255, 255, 255)' && outline.labelFont === '14px 500 normal', outline.labelColor + ' ' + outline.labelFont)
|
||||||
await page.close()
|
await page.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/* Story canvas only: frames that simulate a header width. Never styles the header itself. */
|
/* 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-scroller { max-width: 100%; overflow-x: auto; }
|
||||||
.sw-story-frame { background: #060606; }
|
.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 {
|
.sw-story-composed {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -10,7 +13,7 @@
|
|||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
background: var(--hm-sw-header-object-background);
|
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
|
/* 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. */
|
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; }
|
.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 { 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-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-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; }
|
.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.
|
/* 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. */
|
Header's public API deliberately excludes panel style; package CSS and other Stories stay untouched. */
|
||||||
|
|||||||
@@ -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 filtered = LAYERS.filter(layer => layer.name.toLowerCase().includes(query.trim().toLowerCase()))
|
||||||
const set = (names: string[]) => demo.setHidden(new Set(names))
|
const set = (names: string[]) => demo.setHidden(new Set(names))
|
||||||
return (
|
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="레이어 목록">
|
<SWViewerSection surface="plain" aria-label="레이어 목록">
|
||||||
<div className="sw-viewer-story-toolbar">
|
<div className="sw-viewer-story-toolbar">
|
||||||
<SWViewerInput type="search" placeholder="레이어 검색" aria-label="레이어 검색" value={query} onChange={event => setQuery(event.currentTarget.value)} />
|
<SWViewerInput type="search" placeholder="레이어 검색" aria-label="레이어 검색" value={query} onChange={event => setQuery(event.currentTarget.value)} />
|
||||||
<SWViewerButton onClick={() => set([])}>전체 켜기</SWViewerButton>
|
<SWViewerButton tone="green" onClick={() => set([])}>전체 켜기</SWViewerButton>
|
||||||
<SWViewerButton onClick={() => set(LAYERS.map(layer => layer.name))}>전체 끄기</SWViewerButton>
|
<SWViewerButton tone="green" onClick={() => set(LAYERS.map(layer => layer.name))}>전체 끄기</SWViewerButton>
|
||||||
</div>
|
</div>
|
||||||
{filtered.length === 0 ? <SWViewerHint>검색 결과가 없습니다 ("{query}")</SWViewerHint> : (
|
{filtered.length === 0 ? <SWViewerHint>검색 결과가 없습니다 ("{query}")</SWViewerHint> : (
|
||||||
<ul className="sw-viewer-story-list">
|
<ul className="sw-viewer-story-list">
|
||||||
@@ -152,7 +152,7 @@ export function OptionsPanel({ demo, footer, ...placement }: { demo: ViewerDemo;
|
|||||||
const { options, setOptions } = demo
|
const { options, setOptions } = demo
|
||||||
const set = (patch: Partial<ViewerOptions>) => setOptions({ ...options, ...patch })
|
const set = (patch: Partial<ViewerOptions>) => setOptions({ ...options, ...patch })
|
||||||
return (
|
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>}
|
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}>
|
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="선가중치">
|
<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>
|
<dl className="sw-viewer-story-props">{items.map(([key, value]) => <div key={key}><dt>{key}</dt><dd title={value}>{value}</dd></div>)}</dl>
|
||||||
)
|
)
|
||||||
return (
|
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}>
|
headerActions={selected ? <SWViewerButton variant="ghost" aria-label="선택 확대" title="선택 확대" onClick={() => demo.record("zoomToSelection (미연결)")}><ZoomIn size={16} /></SWViewerButton> : undefined}>
|
||||||
{selected ? <>
|
{selected ? <>
|
||||||
<SWViewerSection surface="plain" title="일반 정보 (General)"><SWViewerBadge>LINE · h=2F1A</SWViewerBadge>{rows(ENTITY_ROWS)}</SWViewerSection>
|
<SWViewerSection surface="plain" title="일반 정보 (General)"><SWViewerBadge>LINE · h=2F1A</SWViewerBadge>{rows(ENTITY_ROWS)}</SWViewerSection>
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background:
|
background: var(--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-viewer-story-room { padding-bottom: 320px; }
|
.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; }
|
.sw-viewer-story-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 12px 4px; }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type SWViewerHeaderGroup, type SWViewerMenuEntry,
|
type SWViewerHeaderGroup, type SWViewerMenuEntry,
|
||||||
} from "@hm-design/sw"
|
} from "@hm-design/sw"
|
||||||
import "@hm-design/sw/styles.css"
|
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 { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||||
import { LAYERS, LayerPanel, OptionsPanel, PropertiesPanel, SAMPLES, useViewerDemo, type ViewerDemo } from "./sw-viewer-fixture"
|
import { LAYERS, LayerPanel, OptionsPanel, PropertiesPanel, SAMPLES, useViewerDemo, type ViewerDemo } from "./sw-viewer-fixture"
|
||||||
import "./sw-stories.css"
|
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} />}>
|
<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 }} />
|
<LayerPanel demo={demo} strategy="absolute" defaultPosition={{ left: 24, top: 16 }} />
|
||||||
<PropertiesPanel demo={demo} strategy="absolute" defaultPosition={{ left: 368, 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></>}>
|
footer={<><SWViewerButton onClick={() => setInputOpen(false)}>취소</SWViewerButton><SWViewerButton variant="primary" onClick={() => { demo.record(`scale=${scale}, snap=${snap}`); setInputOpen(false) }}>적용</SWViewerButton></>}>
|
||||||
<SWViewerSection title="입력">
|
<SWViewerSection title="입력">
|
||||||
<SWViewerField label="축척">{id => <SWViewerInput id={id} value={scale} onChange={event => setScale(event.currentTarget.value)} style={{ width: 120 }} />}</SWViewerField>
|
<SWViewerField label="축척">{id => <SWViewerInput id={id} value={scale} onChange={event => setScale(event.currentTarget.value)} style={{ width: 120 }} />}</SWViewerField>
|
||||||
|
|||||||