diff --git a/docs/sw/sw-viewer-mode-2026-10-19.md b/docs/sw/sw-viewer-mode-2026-10-19.md index e1413ab..a7928d4 100644 --- a/docs/sw/sw-viewer-mode-2026-10-19.md +++ b/docs/sw/sw-viewer-mode-2026-10-19.md @@ -110,3 +110,16 @@ ## E. 최종 판정 **PARTIAL**: 패키지 컴포넌트 구현, Storybook, AI 지침, 주요 UI·인터랙션 검증은 끝났습니다. PASS가 아닌 이유는 요청 원문의 “SW 프로그램 뷰어 모드 통합”이 이번 범위에서 제외되어(사용자 결정) 진행하지 않았기 때문입니다. Commit·Push는 승인 대기입니다. + +## F. 후속 디자인 수정 (Push `7f42923` 이후, 미커밋) + +| 항목 | 적용 | 검증 | +|---|---|---| +| A. 아이콘 외곽선 filter → SVG stroke | 신규 `SWViewerIcon`(공개). 아이콘 뒤 복사본의 도형을 검정 Stroke(원래 두께 + 화면 기준 양쪽 1px, `--hm-sw-viewer-icon-outline-width`), 채운 도형은 검정 채움. 위 아이콘은 그대로. CSS `filter`와 glow 제거 | 계산값: filter `none`, 복사본 stroke 검정 5 unit / 원본 2 unit(24 unit 아이콘을 16px로 그린 경우 1px = 1.5 unit). 3배 캡처에서 모양·선 두께 유지 | +| B. Viewer Popup = SW Popup | `SWViewerPanel`이 공통 `SWPopup`을 렌더링(같은 DOM·CSS). `SWPopup`에 선택 Props `titleActions`, `footer` 추가(기본 동작 동일). 제목 아이콘 Prop 제거, 기본 폭 314. 314px가 아닌 폭은 제목 아트를 오른쪽 정렬 + 왼쪽 평면 확장(이음매 없음). 패널 콘텐츠 토큰을 SW 속성창 토큰(`--hm-sw-property-*`)에 연결 | SW/Popup·Viewer 패널 계산값 동일(1px #A67D03, r4, inset 1px 검정, #E4DBC9, 제목 막대 30px, 제목 #FFC600 14px 700 + 1px 외곽선). 드래그·크기 조절·접기·Escape·모달·앞으로 가져오기 PASS | +| C. SW Header 메뉴 배경 그리드 | Story 공용 변수 `--sw-story-canvas`(Viewer 캔버스와 같은 40px 칸, 1px #266651 18%, #0A0A0B)를 SW Header Overview 작업 영역과 Dropdown 공간에 적용. SW Header 컴포넌트 자체는 변경 없음 | 캡처 확인. 회귀: 이전 배경을 테스트 CSS로 되돌리면 Header·Popup 요소 39/39 픽셀 동일, 실제 차이는 배경 투과 픽셀뿐 | +| D. 레이어 전체 켜기 / 끄기 | `SWViewerButton tone="green"`: 기본 유지, Hover = 확인(primary) 버튼 Hover와 같은 토큰 `--hm-sw-viewer-hover`, 누르는 동안 = primary 면 `--hm-sw-viewer-panel-primary`. 새 HEX 없음. 다른 버튼 영향 없음 | 기본 → Hover → Active → 놓은 뒤 Hover → 이탈 후 기본 계산값 PASS, 확인 버튼 Hover와 정확히 동일 | + +- 해석(C): “Viewer Header의 검정 그리드”는 Header 컴포넌트가 아니라 Story 도면 영역 배경이라 Story 레벨 공용 변수로 적용했습니다. +- 높이(추가 요청): 패키지에 뷰어 영역 고정 높이가 없음을 확인했습니다(Header 52px 한 줄, 팝업 최대 90vh). Storybook 프레임 높이는 Story 전용 값이고, 프로그램 레이아웃 규칙은 `ai/VIEWER.md` §7에 적었습니다. 실제 테스트 프로젝트(`HMDesignSwTest-viewer`)는 다른 세션이 작업 중이라 이 세션에서는 수정하지 않았습니다. 읽기 확인 결과, 화면 높이는 `h-full` 세로 flex에 도면 영역 `flex-1 min-h-0`이고, 렌더러 크기 조정은 window `resize`에만 연결되어 있습니다(컨테이너 크기 변화에는 미대응). +- 검증: `viewer.browser.mjs` 76/76, 요소 회귀 39/39(`--neutral-canvas`), `npm run typecheck` 0 오류, `build-storybook` 성공, `ai-docs.check` 25/25. diff --git a/docs/sw/viewer-qa/outline-states-2x.png b/docs/sw/viewer-qa/outline-states-2x.png index 0934c13..1f6e915 100644 Binary files a/docs/sw/viewer-qa/outline-states-2x.png and b/docs/sw/viewer-qa/outline-states-2x.png differ diff --git a/docs/sw/viewer-qa/outline-viewer-3x.png b/docs/sw/viewer-qa/outline-viewer-3x.png index 4b02a50..a1da4e4 100644 Binary files a/docs/sw/viewer-qa/outline-viewer-3x.png and b/docs/sw/viewer-qa/outline-viewer-3x.png differ diff --git a/docs/sw/viewer-qa/outline-viewer-right-3x.png b/docs/sw/viewer-qa/outline-viewer-right-3x.png index 35b93a4..843d4a5 100644 Binary files a/docs/sw/viewer-qa/outline-viewer-right-3x.png and b/docs/sw/viewer-qa/outline-viewer-right-3x.png differ diff --git a/docs/sw/viewer-qa/package-1440-loaded.png b/docs/sw/viewer-qa/package-1440-loaded.png index 4f7685c..b524102 100644 Binary files a/docs/sw/viewer-qa/package-1440-loaded.png and b/docs/sw/viewer-qa/package-1440-loaded.png differ diff --git a/docs/sw/viewer-qa/package-1440-panels.png b/docs/sw/viewer-qa/package-1440-panels.png index b95284c..eceb9aa 100644 Binary files a/docs/sw/viewer-qa/package-1440-panels.png and b/docs/sw/viewer-qa/package-1440-panels.png differ diff --git a/docs/sw/viewer-qa/package-1440-sample-menu.png b/docs/sw/viewer-qa/package-1440-sample-menu.png index b633a3c..f481346 100644 Binary files a/docs/sw/viewer-qa/package-1440-sample-menu.png and b/docs/sw/viewer-qa/package-1440-sample-menu.png differ diff --git a/docs/sw/viewer-qa/package-logo-element.png b/docs/sw/viewer-qa/package-logo-element.png index 3a90202..4087677 100644 Binary files a/docs/sw/viewer-qa/package-logo-element.png and b/docs/sw/viewer-qa/package-logo-element.png differ diff --git a/docs/sw/viewer-qa/package-logo-none.png b/docs/sw/viewer-qa/package-logo-none.png index 2eb1b1b..ee4f59d 100644 Binary files a/docs/sw/viewer-qa/package-logo-none.png and b/docs/sw/viewer-qa/package-logo-none.png differ diff --git a/docs/sw/viewer-qa/package-logo-src.png b/docs/sw/viewer-qa/package-logo-src.png index 24cdcc2..2a86285 100644 Binary files a/docs/sw/viewer-qa/package-logo-src.png and b/docs/sw/viewer-qa/package-logo-src.png differ diff --git a/docs/sw/viewer-qa/package-responsive-1100.png b/docs/sw/viewer-qa/package-responsive-1100.png index fa0c0f2..317a856 100644 Binary files a/docs/sw/viewer-qa/package-responsive-1100.png and b/docs/sw/viewer-qa/package-responsive-1100.png differ diff --git a/docs/sw/viewer-qa/package-responsive-1440.png b/docs/sw/viewer-qa/package-responsive-1440.png index fcfc112..e49cca9 100644 Binary files a/docs/sw/viewer-qa/package-responsive-1440.png and b/docs/sw/viewer-qa/package-responsive-1440.png differ diff --git a/docs/sw/viewer-qa/package-responsive-520.png b/docs/sw/viewer-qa/package-responsive-520.png index d0969e4..edd40dd 100644 Binary files a/docs/sw/viewer-qa/package-responsive-520.png and b/docs/sw/viewer-qa/package-responsive-520.png differ diff --git a/docs/sw/viewer-qa/package-responsive-860.png b/docs/sw/viewer-qa/package-responsive-860.png index e0b3c1b..cda99bf 100644 Binary files a/docs/sw/viewer-qa/package-responsive-860.png and b/docs/sw/viewer-qa/package-responsive-860.png differ diff --git a/docs/sw/viewer-qa/package-responsive-more-open.png b/docs/sw/viewer-qa/package-responsive-more-open.png index 1e4cefb..4c111b6 100644 Binary files a/docs/sw/viewer-qa/package-responsive-more-open.png and b/docs/sw/viewer-qa/package-responsive-more-open.png differ diff --git a/docs/sw/viewer-qa/reference-1440-loaded.png b/docs/sw/viewer-qa/reference-1440-loaded.png index c1af8bd..f10eb00 100644 Binary files a/docs/sw/viewer-qa/reference-1440-loaded.png and b/docs/sw/viewer-qa/reference-1440-loaded.png differ diff --git a/docs/sw/viewer-qa/reference-1440-sample-menu.png b/docs/sw/viewer-qa/reference-1440-sample-menu.png index d210e6c..e44670f 100644 Binary files a/docs/sw/viewer-qa/reference-1440-sample-menu.png and b/docs/sw/viewer-qa/reference-1440-sample-menu.png differ diff --git a/docs/sw/viewer-qa/regression/regression-neutral-canvas.json b/docs/sw/viewer-qa/regression/regression-neutral-canvas.json new file mode 100644 index 0000000..ffa3b2b --- /dev/null +++ b/docs/sw/viewer-qa/regression/regression-neutral-canvas.json @@ -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" + } + ] +} diff --git a/docs/sw/viewer-qa/regression/regression.json b/docs/sw/viewer-qa/regression/regression.json index 5b25aa3..c9c3b1a 100644 --- a/docs/sw/viewer-qa/regression/regression.json +++ b/docs/sw/viewer-qa/regression/regression.json @@ -3,234 +3,633 @@ "next": "http://127.0.0.1:6034", "results": [ { - "id": "overview", + "id": "sw-header--overview", "width": 1920, - "size": "1920x1000 / 1920x1000", - "diffPixels": 0, - "result": "PASS" + "elements": "1 / 1", + "elementDiffPixels": [ + 667 + ], + "fullPageDiffPixels": 716941, + "result": "FAIL" }, { - "id": "overview", + "id": "sw-header--overview", "width": 1180, - "size": "1180x1000 / 1180x1000", - "diffPixels": 0, + "elements": "1 / 1", + "elementDiffPixels": [ + 0 + ], + "fullPageDiffPixels": 606746, "result": "PASS" }, { - "id": "overview", + "id": "sw-header--overview", "width": 768, - "size": "768x1000 / 768x1000", - "diffPixels": 0, + "elements": "1 / 1", + "elementDiffPixels": [ + 0 + ], + "fullPageDiffPixels": 237853, "result": "PASS" }, { - "id": "interactive-states", + "id": "sw-header--interactive-states", "width": 1920, - "size": "1920x2517 / 1920x2517", - "diffPixels": 0, + "elements": "16 / 16", + "elementDiffPixels": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "interactive-states", + "id": "sw-header--interactive-states", "width": 1180, - "size": "1180x2527 / 1180x2527", - "diffPixels": 0, + "elements": "16 / 16", + "elementDiffPixels": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "interactive-states", + "id": "sw-header--interactive-states", "width": 768, - "size": "768x2623 / 768x2623", - "diffPixels": 0, + "elements": "16 / 16", + "elementDiffPixels": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "logo-slot", + "id": "sw-header--logo-slot", "width": 1920, - "size": "1920x1000 / 1920x1000", - "diffPixels": 0, + "elements": "4 / 4", + "elementDiffPixels": [ + 0, + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "logo-slot", + "id": "sw-header--logo-slot", "width": 1180, - "size": "1180x1000 / 1180x1000", - "diffPixels": 0, + "elements": "4 / 4", + "elementDiffPixels": [ + 0, + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "logo-slot", + "id": "sw-header--logo-slot", "width": 768, - "size": "768x1000 / 768x1000", - "diffPixels": 0, + "elements": "4 / 4", + "elementDiffPixels": [ + 0, + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "document-dropdown", + "id": "sw-header--document-dropdown", "width": 1920, - "size": "1920x9112 / 1920x9112", - "diffPixels": 0, + "elements": "18 / 18", + "elementDiffPixels": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fullPageDiffPixels": 6111124, "result": "PASS" }, { - "id": "document-dropdown", + "id": "sw-header--document-dropdown", "width": 1180, - "size": "1180x9122 / 1180x9122", - "diffPixels": 0, - "result": "PASS" + "elements": "18 / 18", + "elementDiffPixels": [ + 793, + 0, + 0, + 0, + 0, + 0, + 1109, + 793, + 793, + 793, + 793, + 793, + 793, + 0, + 0, + 0, + 0, + 48 + ], + "fullPageDiffPixels": 5602299, + "result": "FAIL" }, { - "id": "document-dropdown", + "id": "sw-header--document-dropdown", "width": 768, - "size": "768x9298 / 768x9298", - "diffPixels": 0, - "result": "PASS" + "elements": "18 / 18", + "elementDiffPixels": [ + 405, + 0, + 0, + 0, + 0, + 0, + 721, + 405, + 405, + 405, + 405, + 405, + 405, + 0, + 0, + 0, + 0, + 48 + ], + "fullPageDiffPixels": 3410951, + "result": "FAIL" }, { - "id": "responsive-more", + "id": "sw-header--responsive-more", "width": 1920, - "size": "1920x10042 / 1920x10042", - "diffPixels": 0, + "elements": "19 / 19", + "elementDiffPixels": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "fullPageDiffPixels": 8338418, "result": "PASS" }, { - "id": "responsive-more", + "id": "sw-header--responsive-more", "width": 1180, - "size": "1180x10052 / 1180x10052", - "diffPixels": 0, - "result": "PASS" + "elements": "19 / 19", + "elementDiffPixels": [ + 0, + 818, + 50, + 0, + 0, + 0, + 0, + 0, + 0, + 47, + 48, + 48, + 48, + 48, + 47, + 48, + 47, + 48, + 48 + ], + "fullPageDiffPixels": 7674707, + "result": "FAIL" }, { - "id": "responsive-more", + "id": "sw-header--responsive-more", "width": 768, - "size": "768x10048 / 768x10048", - "diffPixels": 0, - "result": "PASS" + "elements": "19 / 19", + "elementDiffPixels": [ + 0, + 721, + 50, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 47, + 48, + 47, + 48, + 48 + ], + "fullPageDiffPixels": 4880711, + "result": "FAIL" }, { - "id": "k-guide", + "id": "sw-header--k-guide", "width": 1920, - "size": "1920x1037 / 1920x1037", - "diffPixels": 0, + "elements": "1 / 1", + "elementDiffPixels": [ + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "k-guide", + "id": "sw-header--k-guide", "width": 1180, - "size": "1180x1028 / 1180x1028", - "diffPixels": 0, + "elements": "1 / 1", + "elementDiffPixels": [ + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "k-guide", + "id": "sw-header--k-guide", "width": 768, - "size": "768x1023 / 768x1023", - "diffPixels": 0, + "elements": "1 / 1", + "elementDiffPixels": [ + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "property-panel-compare", + "id": "sw-header--property-panel-compare", "width": 1920, - "size": "1920x1000 / 1920x1000", - "diffPixels": 0, + "elements": "4 / 4", + "elementDiffPixels": [ + 0, + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "property-panel-compare", + "id": "sw-header--property-panel-compare", "width": 1180, - "size": "1180x1000 / 1180x1000", - "diffPixels": 0, + "elements": "4 / 4", + "elementDiffPixels": [ + 0, + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "property-panel-compare", + "id": "sw-header--property-panel-compare", "width": 768, - "size": "768x1000 / 768x1000", - "diffPixels": 0, + "elements": "4 / 4", + "elementDiffPixels": [ + 0, + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "composed-controls", + "id": "sw-header--composed-controls", "width": 1920, - "size": "1920x1000 / 1920x1000", - "diffPixels": 0, + "elements": "0 / 0", + "elementDiffPixels": [], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "composed-controls", + "id": "sw-header--composed-controls", "width": 1180, - "size": "1180x1000 / 1180x1000", - "diffPixels": 0, + "elements": "0 / 0", + "elementDiffPixels": [], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "composed-controls", + "id": "sw-header--composed-controls", "width": 768, - "size": "768x1000 / 768x1000", - "diffPixels": 0, + "elements": "0 / 0", + "elementDiffPixels": [], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "document-open", + "id": "sw-header--document-open", "width": 1920, - "size": "1920x10771 / 1920x10771", - "diffPixels": 0, - "result": "PASS" + "elements": "23 / 23", + "elementDiffPixels": [ + 0, + 0, + 0, + 0, + 1199, + 1198, + 1180, + 1078, + 899, + 768, + 640, + 520, + 388, + 0, + 1200, + 1200, + 1180, + 1080, + 900, + 766, + 640, + 520, + 390 + ], + "fullPageDiffPixels": 10359147, + "result": "FAIL" }, { - "id": "document-open", + "id": "sw-header--document-open", "width": 1180, - "size": "1180x10761 / 1180x10761", - "diffPixels": 0, - "result": "PASS" + "elements": "23 / 23", + "elementDiffPixels": [ + 0, + 0, + 0, + 0, + 1108, + 1108, + 1110, + 1127, + 948, + 817, + 689, + 569, + 437, + 0, + 1110, + 1110, + 1110, + 1129, + 949, + 815, + 689, + 569, + 439 + ], + "fullPageDiffPixels": 9548042, + "result": "FAIL" }, { - "id": "document-open", + "id": "sw-header--document-open", "width": 768, - "size": "768x10797 / 768x10797", - "diffPixels": 0, - "result": "PASS" + "elements": "23 / 23", + "elementDiffPixels": [ + 0, + 0, + 0, + 0, + 721, + 721, + 722, + 722, + 721, + 722, + 689, + 569, + 437, + 0, + 722, + 722, + 722, + 722, + 722, + 720, + 689, + 569, + 439 + ], + "fullPageDiffPixels": 6118190, + "result": "FAIL" }, { - "id": "hidden-sub-controls", + "id": "sw-header--hidden-sub-controls", "width": 1920, - "size": "1920x1710 / 1920x1710", - "diffPixels": 0, - "result": "PASS" + "elements": "3 / 3", + "elementDiffPixels": [ + 0, + 1199, + 0 + ], + "fullPageDiffPixels": 1178934, + "result": "FAIL" }, { - "id": "hidden-sub-controls", + "id": "sw-header--hidden-sub-controls", "width": 1180, - "size": "1180x1700 / 1180x1700", - "diffPixels": 0, - "result": "PASS" + "elements": "3 / 3", + "elementDiffPixels": [ + 0, + 1110, + 0 + ], + "fullPageDiffPixels": 1084137, + "result": "FAIL" }, { - "id": "hidden-sub-controls", + "id": "sw-header--hidden-sub-controls", "width": 768, - "size": "768x1676 / 768x1676", - "diffPixels": 0, - "result": "PASS" + "elements": "3 / 3", + "elementDiffPixels": [ + 0, + 722, + 0 + ], + "fullPageDiffPixels": 676260, + "result": "FAIL" }, { - "id": "detail-buttons", + "id": "sw-header--detail-buttons", "width": 1920, - "size": "1920x1000 / 1920x1000", - "diffPixels": 0, + "elements": "0 / 0", + "elementDiffPixels": [], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "detail-buttons", + "id": "sw-header--detail-buttons", "width": 1180, - "size": "1180x1000 / 1180x1000", - "diffPixels": 0, + "elements": "0 / 0", + "elementDiffPixels": [], + "fullPageDiffPixels": 0, "result": "PASS" }, { - "id": "detail-buttons", + "id": "sw-header--detail-buttons", "width": 768, - "size": "768x1000 / 768x1000", - "diffPixels": 0, + "elements": "0 / 0", + "elementDiffPixels": [], + "fullPageDiffPixels": 0, + "result": "PASS" + }, + { + "id": "sw-popup--custom-content", + "width": 1920, + "elements": "3 / 3", + "elementDiffPixels": [ + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, + "result": "PASS" + }, + { + "id": "sw-popup--custom-content", + "width": 1180, + "elements": "3 / 3", + "elementDiffPixels": [ + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, + "result": "PASS" + }, + { + "id": "sw-popup--custom-content", + "width": 768, + "elements": "3 / 3", + "elementDiffPixels": [ + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, + "result": "PASS" + }, + { + "id": "sw-popup--multiple-popups", + "width": 1920, + "elements": "3 / 3", + "elementDiffPixels": [ + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, + "result": "PASS" + }, + { + "id": "sw-popup--multiple-popups", + "width": 1180, + "elements": "3 / 3", + "elementDiffPixels": [ + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, + "result": "PASS" + }, + { + "id": "sw-popup--multiple-popups", + "width": 768, + "elements": "3 / 3", + "elementDiffPixels": [ + 0, + 0, + 0 + ], + "fullPageDiffPixels": 0, "result": "PASS" } ] diff --git a/docs/sw/viewer-qa/report.json b/docs/sw/viewer-qa/report.json index ffa63c7..c7cb7db 100644 --- a/docs/sw/viewer-qa/report.json +++ b/docs/sw/viewer-qa/report.json @@ -374,7 +374,7 @@ { "name": "corner resize changes size", "result": "PASS", - "detail": "320x420 → 263x323" + "detail": "314x420 → 257x323" }, { "name": "drag moves panel", @@ -396,13 +396,18 @@ "result": "PASS", "detail": "" }, + { + "name": "전체 켜기: default kept, green hover / active, back after release", + "result": "PASS", + "detail": "rgb(255, 255, 255) rgb(18, 51, 40) | rgb(38, 102, 81) rgb(255, 255, 255) | rgb(18, 51, 40) rgb(255, 255, 255) | rgb(38, 102, 81) rgb(255, 255, 255) | rgb(255, 255, 255) rgb(18, 51, 40)" + }, { "name": "panel content callback", "result": "PASS", "detail": "" }, { - "name": "close hover #E40000 / white X", + "name": "close hover = SWPopup hover asset", "result": "PASS", "detail": "" }, @@ -414,7 +419,12 @@ { "name": "last touched panel on top", "result": "PASS", - "detail": "layer z 37, options z 36" + "detail": "layer z 47, options z 46" + }, + { + "name": "전체 켜기 hover = 라이선스 확인 hover (same token)", + "result": "PASS", + "detail": "rgb(38, 102, 81) rgb(255, 255, 255) / rgb(38, 102, 81) rgb(255, 255, 255)" }, { "name": "modal popup opens with backdrop", @@ -582,9 +592,9 @@ "detail": "rgb(0, 0, 0) 1px 0px 0px, rgb(0, 0, 0) 0.92px 0.38px 0px, rg" }, { - "name": "icon outline: 4 x 1px black drop-shadows + #061A14 glow", + "name": "icon outline: SVG stroke copy, no CSS filter", "result": "PASS", - "detail": "drop-shadow(rgb(0, 0, 0) 1px 0px 0px) drop-shadow(rgb(0, 0, 0) -1px 0px 0px) drop-shadow(r" + "detail": "{\"filter\":\"none\",\"copyStroke\":\"rgb(0, 0, 0)\",\"copyWidth\":5,\"glyphStroke\":\"rgb(255, 255, 255)\",\"glyphWidth\":2}" }, { "name": "fill / size / weight / spacing kept", diff --git a/docs/sw/viewer-qa/story-compare-with-sw-header.png b/docs/sw/viewer-qa/story-compare-with-sw-header.png index 19d5de2..76546fc 100644 Binary files a/docs/sw/viewer-qa/story-compare-with-sw-header.png and b/docs/sw/viewer-qa/story-compare-with-sw-header.png differ diff --git a/docs/sw/viewer-qa/story-content-variants.png b/docs/sw/viewer-qa/story-content-variants.png index 767dee5..35fe8dd 100644 Binary files a/docs/sw/viewer-qa/story-content-variants.png and b/docs/sw/viewer-qa/story-content-variants.png differ diff --git a/docs/sw/viewer-qa/story-dropdowns.png b/docs/sw/viewer-qa/story-dropdowns.png index 993b5ca..751fa33 100644 Binary files a/docs/sw/viewer-qa/story-dropdowns.png and b/docs/sw/viewer-qa/story-dropdowns.png differ diff --git a/docs/sw/viewer-qa/story-logo.png b/docs/sw/viewer-qa/story-logo.png index 7cf6af1..4ef6877 100644 Binary files a/docs/sw/viewer-qa/story-logo.png and b/docs/sw/viewer-qa/story-logo.png differ diff --git a/docs/sw/viewer-qa/story-menu-groups.png b/docs/sw/viewer-qa/story-menu-groups.png index 4e078fb..c4e5a35 100644 Binary files a/docs/sw/viewer-qa/story-menu-groups.png and b/docs/sw/viewer-qa/story-menu-groups.png differ diff --git a/docs/sw/viewer-qa/story-responsive.png b/docs/sw/viewer-qa/story-responsive.png index 1556b8c..634d727 100644 Binary files a/docs/sw/viewer-qa/story-responsive.png and b/docs/sw/viewer-qa/story-responsive.png differ diff --git a/docs/sw/viewer-qa/story-settings-panel.png b/docs/sw/viewer-qa/story-settings-panel.png index 80a14d0..ed6d041 100644 Binary files a/docs/sw/viewer-qa/story-settings-panel.png and b/docs/sw/viewer-qa/story-settings-panel.png differ diff --git a/docs/sw/viewer-qa/story-states.png b/docs/sw/viewer-qa/story-states.png index 4746e6f..86b9e88 100644 Binary files a/docs/sw/viewer-qa/story-states.png and b/docs/sw/viewer-qa/story-states.png differ diff --git a/docs/sw/viewer-qa/story-variable-size.png b/docs/sw/viewer-qa/story-variable-size.png index 24e55ff..c594a4d 100644 Binary files a/docs/sw/viewer-qa/story-variable-size.png and b/docs/sw/viewer-qa/story-variable-size.png differ diff --git a/docs/sw/viewer-qa/story-viewer-header.png b/docs/sw/viewer-qa/story-viewer-header.png index 346a620..20d5609 100644 Binary files a/docs/sw/viewer-qa/story-viewer-header.png and b/docs/sw/viewer-qa/story-viewer-header.png differ diff --git a/packages/sw/ai/VIEWER.md b/packages/sw/ai/VIEWER.md index 288f314..f58cea3 100644 --- a/packages/sw/ai/VIEWER.md +++ b/packages/sw/ai/VIEWER.md @@ -39,7 +39,7 @@ - **반응형(참고 뷰어와 동일):** 항목별 `collapseBelow`로 More(⋯, 베이지 260px Dropdown)로 이동합니다. 참고 뷰어 기준값: 샘플·도면 공간·2.5D·어두운 배경 `1180`, 패널 토글 `900`, 파일 열기·전체 보기 `iconOnlyBelow: 560`. 1180px 미만은 간격 2px, 560px 미만은 상태 글자 숨김(CSS container). 가로 스크롤은 만들지 않습니다. - More는 그룹/항목 단위 구획으로 자동 구성됩니다: menu·tabs는 자기 구획(`moreLabel`), button·toggle·switch는 그룹 구획(`group.moreLabel ?? group.label`). toggle·switch는 체크 항목, tabs는 라디오 항목이 됩니다. - **상태 표현:** Hover = #266651 면, Active·Pressed·열림·선택 탭 = 글자·아이콘 #FFC600(선택 박스 없음), Disabled = 60% 불투명, Focus = 2px #FFC600 외곽선. -- **글자·아이콘 외곽선(SWHeader와 동일):** 14px 글자 `--hm-sw-header-text-outline`(검정 1.3px), 11px 캡션·개수·상태 `--hm-sw-header-label-outline`(검정 1px), 아이콘 `--hm-sw-viewer-icon-outline`(SWHeader 아이콘 자산의 1px 검정 바깥 Stroke + #061A14 glow를 `drop-shadow`로 재현, 아이콘 윤곽을 따름). 색·크기·굵기·간격은 그대로이고 Hover/Active는 채움 색만 바뀝니다. Dropdown·패널 안에는 외곽선이 없습니다(SWHeader More 메뉴와 같음). 프로그램 아이콘은 `currentColor`로 그리면 상태 색과 외곽선이 자동 적용됩니다. +- **글자·아이콘 외곽선(SWHeader와 동일):** 14px 글자 `--hm-sw-header-text-outline`(검정 1.3px), 11px 캡션·개수·상태 `--hm-sw-header-label-outline`(검정 1px), 아이콘 `SWViewerIcon`(SWHeader 아이콘 자산의 1px 검정 바깥 Stroke를 **SVG stroke**로 재현: 아이콘 뒤 복사본의 모든 도형을 검정, 원래 선 두께 + 화면 기준 양쪽 1px(`--hm-sw-viewer-icon-outline-width`)로 그림. 위 아이콘은 그대로라 색·모양·선 두께 유지. CSS `filter` 미사용, glow는 생략). Header 항목·`SWViewerHeaderButton`의 `icon`은 자동 적용, `custom` / `trailing`의 직접 그린 아이콘은 ``으로 감쌉니다. 색·크기·굵기·간격은 그대로이고 Hover/Active는 채움 색만 바뀝니다. Dropdown·패널 안에는 외곽선이 없습니다(SWHeader More 메뉴와 같음). 프로그램 아이콘은 `currentColor`로 그리면 상태 색과 외곽선이 자동 적용됩니다. ## 3. `SWViewerMenu` / `SWViewerMenuEntry` @@ -50,15 +50,15 @@ ## 4. `SWViewerPanel` — 팝업 프레임 + 자유 콘텐츠 -외형(베이지 #E0DACE 본문, 2px #897445 테두리, #221D13 제목 막대, #FFC600 제목, 접기, 곡선 녹색 닫기 → Hover #E40000)과 공통 동작은 패키지가, 내용은 `children`·`footer`가 담당합니다. +프레임은 **공통 SW 팝업 `SWPopup`과 같은 컴포넌트·DOM**입니다(30px 제목 막대·Figma 제목 아트, #FFC600 700 제목 + 검정 1px 외곽선, SW 닫기 버튼, 1px #A67D03 테두리, 반경 4, #E4DBC9 면). 뷰어는 여기에 드래그·모서리 크기 조절·접기·경계 유지·앞으로 가져오기·모달을 더합니다. 314px가 아닌 폭에서도 제목 아트 곡선은 닫기 버튼에 붙어 있고 왼쪽 평면이 늘어납니다. 내용은 `children`·`footer`가 담당합니다. 일반 SW 팝업은 `SWPopup`(`titleActions?`, `footer?` 추가)을 그대로 씁니다. | Props | 설명 | |---|---| -| `title`, `icon?`, `open`, `onClose`, `closeLabel?` | 필수는 `title`·`open`·`onClose` | +| `title`, `open`, `onClose`, `closeLabel?` | 필수는 `title`·`open`·`onClose`. 제목 아이콘은 없습니다(SW 팝업 디자인) | | `children` | 스크롤 본문. `SWViewerSection`(카드/plain, 12px 제목), `SWViewerField`(라벨 + 컨트롤, `inline` / `stacked` + `below`), `SWViewerSwitch`, `SWViewerSlider`, `SWViewerInput`, `SWViewerButton`, `SWViewerBadge`, `SWViewerHint`, 프로그램 목록·표 등 무엇이든 | -| `footer?` | 하단 버튼 영역(본문 스크롤과 무관하게 고정). `SWViewerButton variant="outline" \| "primary"` | -| `headerActions?` | 제목 막대 추가 버튼: `SWViewerButton variant="ghost"` (기본값 복원, 선택 확대 등) | -| `width?`(320), `height?`(420, `"auto"` 가능), `minWidth?`(250), `minHeight?`(140) | 메뉴별 크기. 최대 90vw × 90vh | +| `footer?` | 하단 버튼 영역(본문 스크롤과 무관하게 고정). `SWViewerButton variant="outline" \| "primary"`. `tone="green"` = 기본은 그대로, Hover는 primary(확인) 버튼 Hover와 같은 토큰 `--hm-sw-viewer-hover`, 누르는 동안 primary 면 `--hm-sw-viewer-panel-primary` (예: 레이어 전체 켜기 / 전체 끄기) | +| `headerActions?` | 제목 막대 추가 버튼: `SWViewerButton variant="ghost"` (26px, 글자색만 Hover #FFC600 — SW 제목 막대 방식) | +| `width?`(314 = SWPopup), `height?`(420, `"auto"` 가능), `minWidth?`(250), `minHeight?`(140) | 메뉴별 크기. 최대 90vw × 90vh | | `defaultPosition?` | `{ left, top }` 또는 `"top-end"`(기본) / `"top-start"` / `"center"`(modal 기본), `inset?: { top?, side? }`(기본 56 / 24 = 52px Header 아래) | | `strategy?` | `fixed`(기본, 화면) / `absolute`(가장 가까운 positioned 조상 안 — 미리보기·임베드) | | `draggable?`, `resizable?`, `collapsible?`, `defaultCollapsed?`, `collapsed?`, `onCollapsedChange?`, `onPositionChange?`, `onSizeChange?` | 제목 막대 드래그, 오른쪽 아래 모서리 크기 조절, 접기(버튼·제목 더블클릭). 이동·크기는 경계 10px 안 | @@ -101,4 +101,5 @@ const groups: SWViewerHeaderGroup[] = [ ## 7. 디자인 변경 시 주의 - 크기(52px Header, 34px 버튼, 40px 토글·탭·스위치 칸, 41~42px field 면, 72px 로고 면)와 색은 참고 뷰어 실측값입니다. 파일명·객체 수정·속성 면 배경은 SWHeader와 같은 사용자 확정 토큰(`--hm-sw-header-document-*`, `object-*`, `property-*`)을 공유하므로 바꾸면 두 Header가 함께 바뀝니다. +- **높이:** 패키지는 뷰어(도면) 영역 높이를 정하지 않습니다. Header는 52px 한 줄, 팝업만 최대 90vh입니다. 화면 높이는 앱의 상위 레이아웃이 정하고(예: `html, body, #root { height: 100% }` → 세로 flex, Header 다음 도면 영역 `flex: 1; min-height: 0`), 도면 영역 크기 변화에 맞춰 렌더러 크기를 바꾸는 것은 프로그램 담당입니다(ResizeObserver 권장). Storybook 예시의 고정 높이는 미리보기 프레임 값일 뿐 컴포넌트 값이 아닙니다. - Light/Dark 분기는 없습니다. 참고 뷰어와의 알려진 차이: 도면 공간(tabs) field 면 높이 42px(참고 41px — 참고 Tabs 루트가 39px인 구현 부산물), 크기 조절은 네이티브 `resize` 대신 같은 위치의 모서리 Grip(모든 브라우저 동작), 패널 Escape 닫기 추가, 로고 기본 표시 없음(사용자 결정). diff --git a/packages/sw/ai/examples/ExampleH-ViewerMode.tsx b/packages/sw/ai/examples/ExampleH-ViewerMode.tsx index 623c9e9..0fadf01 100644 --- a/packages/sw/ai/examples/ExampleH-ViewerMode.tsx +++ b/packages/sw/ai/examples/ExampleH-ViewerMode.tsx @@ -60,13 +60,13 @@ export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => vo setAbout(true)} /> } /> - setPanels({ ...panels, layers: false })}> + setPanels({ ...panels, layers: false })}> 프로그램의 레이어 목록을 여기에 둡니다. - setPanels({ ...panels, options: false })} width={340} height="auto" inset={{ side: 356 }} + setPanels({ ...panels, options: false })} width={340} height="auto" inset={{ side: 356 }} footer={ { onEvent?.("options:apply"); setPanels({ ...panels, options: false }) }}>적용}> {id => } diff --git a/packages/sw/src/components/sw-popup/SWPopup.tsx b/packages/sw/src/components/sw-popup/SWPopup.tsx index 1945be5..8999472 100644 --- a/packages/sw/src/components/sw-popup/SWPopup.tsx +++ b/packages/sw/src/components/sw-popup/SWPopup.tsx @@ -17,6 +17,10 @@ export interface SWPopupProps extends Omit, "titl autoFocus?: boolean /** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */ anchorRef?: RefObject + /** 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, "titl * content up to the viewport (the body scrolls). Non-modal (no dim, no portal, no focus trap); the host decides * when it is shown and where (`style`, or `anchorRef`). Wrap it in outside SWHeader. */ -export function SWPopup({ closeLabel = "닫기", ...props }: SWPopupProps) { - return +export function SWPopup({ closeLabel = "닫기", footer, ...props }: SWPopupProps) { + return } export interface SWPopupSectionProps extends Omit, "title"> { diff --git a/packages/sw/src/components/sw-viewer/SWViewerControls.tsx b/packages/sw/src/components/sw-viewer/SWViewerControls.tsx index 7fb5828..d02d42b 100644 --- a/packages/sw/src/components/sw-viewer/SWViewerControls.tsx +++ b/packages/sw/src/components/sw-viewer/SWViewerControls.tsx @@ -1,4 +1,5 @@ import { forwardRef, useId, type ButtonHTMLAttributes, type HTMLAttributes, type InputHTMLAttributes, type ReactNode } from "react" +import { SWViewerIcon } from "./SWViewerIcon" import "../../theme/sw-viewer.css" const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(" ") @@ -47,7 +48,7 @@ const StatusIcon = ({ state }: { state: SWViewerStatusState }) => state === "idl export function SWViewerStatus({ state, label, detail, className, ...rest }: SWViewerStatusProps) { return (
- + {state !== "idle" && } {label ?? statusLabel[state]} {detail && : {detail}}
@@ -60,14 +61,16 @@ export interface SWViewerButtonProps extends ButtonHTMLAttributes(function SWViewerButton( - { variant = "outline", icon, children, className, type = "button", ...rest }, ref, + { variant = "outline", tone, icon, children, className, type = "button", ...rest }, ref, ) { return ( - diff --git a/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx b/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx index 69dc9f1..89e6ccd 100644 --- a/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx +++ b/packages/sw/src/components/sw-viewer/SWViewerHeader.tsx @@ -1,6 +1,7 @@ import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react" import { SWViewerMenu } from "./SWViewerMenu" import { SWViewerSwitch } from "./SWViewerControls" +import { SWViewerIcon } from "./SWViewerIcon" import type { SWViewerCommand, SWViewerHeaderGroup, SWViewerHeaderItem, SWViewerMenuEntry } from "./types" import "../../theme/sw-viewer.css" @@ -97,9 +98,9 @@ function MenuButton({ item, iconOnly, onCommand }: { item: Extract (open ? setOpen(false) : show("menu"))} onKeyDown={event => { if (event.key === "ArrowDown" || ((event.key === "Enter" || event.key === " ") && !open)) { event.preventDefault(); show("first") } }}> - {item.icon && } + {item.icon && } {!iconOnly && {item.label}} - + onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} /> @@ -190,7 +191,7 @@ export const SWViewerHeaderButton = forwardRef - {icon && } + {icon && } {!iconOnly && {label}} ) @@ -272,7 +273,7 @@ export const SWViewerHeader = forwardRef(funct aria-label={moreLabel} title="더보기" aria-haspopup="menu" aria-expanded={moreOpen} onClick={() => { setMoreFocus("menu"); setMoreOpen(!moreOpen) }} onKeyDown={event => { if (event.key === "ArrowDown") { event.preventDefault(); setMoreFocus("first"); setMoreOpen(true) } }}> - + { diff --git a/packages/sw/src/components/sw-viewer/SWViewerIcon.tsx b/packages/sw/src/components/sw-viewer/SWViewerIcon.tsx new file mode 100644 index 0000000..f027081 --- /dev/null +++ b/packages/sw/src/components/sw-viewer/SWViewerIcon.tsx @@ -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(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(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 ( + + ) +} diff --git a/packages/sw/src/components/sw-viewer/SWViewerPanel.tsx b/packages/sw/src/components/sw-viewer/SWViewerPanel.tsx index 8555c3d..6988673 100644 --- a/packages/sw/src/components/sw-viewer/SWViewerPanel.tsx +++ b/packages/sw/src/components/sw-viewer/SWViewerPanel.tsx @@ -1,16 +1,15 @@ -import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react" +import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react" +import { SWPopup } from "../sw-popup/SWPopup" import "../../theme/sw-viewer.css" export type SWViewerPanelPlacement = "top-end" | "top-start" | "center" export interface SWViewerPanelProps extends Omit, "title"> { - /** Title (14px 700 #FFC600, ellipsis). Also the accessible name. */ + /** Title (SWPopup title: 700, #FFC600, black 1px outline, ellipsis). Also the accessible name. */ title: string - /** 16px title icon supplied by the program. */ - icon?: ReactNode /** Shown when true. Unmounted otherwise (no hidden DOM). */ open: boolean - /** Close button, and Escape while focus is inside the panel. */ + /** Close button, and Escape (inside the panel, or anywhere while it is the top SW popup — SWPopup behavior). */ onClose: () => void closeLabel?: string /** Extra title-bar buttons before collapse / close (use `SWViewerButton variant="ghost"`). */ @@ -19,7 +18,7 @@ export interface SWViewerPanelProps extends Omit, children?: ReactNode /** Bottom button area (e.g. 취소 / 적용). Stays visible while the body scrolls. */ footer?: ReactNode - /** Initial size. Default 320 x 420 (reference panel). `height: "auto"` follows the content. */ + /** Initial size. Default 314 (the SWPopup width) x 420. `height: "auto"` follows the content. */ width?: number height?: number | "auto" minWidth?: number @@ -48,7 +47,7 @@ export interface SWViewerPanelProps extends Omit, onSizeChange?: (size: { width: number; height: number }) => void } -let topLayer = 30 +let topLayer = 40 const EDGE = 10 const INTERACTIVE = "button, input, select, textarea, a, label, [data-panel-no-drag]" @@ -56,8 +55,8 @@ const Chevron = ({ down }: { down: boolean }) => ( ) -function bounds(element: HTMLElement | null, strategy: "fixed" | "absolute") { - const parent = strategy === "absolute" ? element?.offsetParent as HTMLElement | null : null +function bounds(element: HTMLElement, strategy: "fixed" | "absolute") { + const parent = strategy === "absolute" ? element.offsetParent as HTMLElement | null : null return parent ? { width: parent.clientWidth, height: parent.clientHeight } : { width: document.documentElement.clientWidth, height: window.innerHeight } } @@ -70,20 +69,21 @@ function clamp(left: number, top: number, element: HTMLElement, strategy: "fixed } /** - * SW viewer popup / settings window (reference DWG/DXF viewer floating panel): #E0DACE body, 2px #897445 border, - * #221D13 title bar with #FFC600 title, collapse and the curved green close (#E40000 on hover). Non-modal by - * default: drag by the title bar, corner resize, collapse, stays inside the bounds, Escape inside closes, the - * last touched panel comes to the front. The frame is fixed; `children` / `footer` are free. + * SW viewer popup / settings window. The frame is the common SW popup (`SWPopup`: title bar, title, close, + * border, radius, shadow — the same component and DOM as every SW popup); this adds the viewer window behavior: + * drag by the title bar, corner resize, collapse, placement inside the bounds, bring to front, optional modal. + * `children` / `footer` are free content. */ export function SWViewerPanel({ - title, icon, open, onClose, closeLabel = "닫기", headerActions, children, footer, - width = 320, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed", + title, open, onClose, closeLabel = "닫기", headerActions, children, footer, + width = 314, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed", draggable = true, resizable = true, collapsible = true, defaultCollapsed = false, collapsed: collapsedProp, onCollapsedChange, - modal = false, autoFocus = true, onPositionChange, onSizeChange, className, style, onKeyDown, onPointerDownCapture, onFocusCapture, ...rest + modal = false, autoFocus = true, onPositionChange, onSizeChange, id: idProp, className, style, onKeyDown, + onPointerDownCapture, onFocusCapture, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, ...rest }: SWViewerPanelProps) { - const ref = useRef(null) - const titleId = useId() - const closeGradient = `hm-sw-viewer-close-${useId().replace(/:/g, "")}` + const generatedId = useId() + const id = idProp ?? `hm-sw-viewer-panel-${generatedId.replace(/:/g, "")}` + const element = () => document.getElementById(id) as HTMLDivElement | null const [position, setPosition] = useState<{ left: number; top: number } | null>(null) const [layer, setLayer] = useState(() => ++topLayer) const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed) @@ -96,10 +96,10 @@ export function SWViewerPanel({ positionRef.current = position const placement = defaultPosition ?? (modal ? "center" : "top-end") - // Initial position, measured once per opening; then kept inside the bounds when they change. + // Initial position, measured once per opening (before paint); then kept inside the bounds when they change. useLayoutEffect(() => { if (!open) { setPosition(null); setSize(null); return } - const panel = ref.current + const panel = element() if (!panel) return const box = bounds(panel, strategy) const top = inset?.top ?? 56, side = inset?.side ?? 24 @@ -107,7 +107,7 @@ export function SWViewerPanel({ : placement === "center" ? { left: (box.width - panel.offsetWidth) / 2, top: (box.height - panel.offsetHeight) / 2 } : { left: placement === "top-end" ? box.width - panel.offsetWidth - side : side, top } setPosition(clamp(start.left, start.top, panel, strategy)) - const keepInside = () => { const current = positionRef.current; if (current && ref.current) setPosition(clamp(current.left, current.top, ref.current, strategy)) } + const keepInside = () => { const current = positionRef.current, node = element(); if (current && node) setPosition(clamp(current.left, current.top, node, strategy)) } window.addEventListener("resize", keepInside) const parent = strategy === "absolute" ? panel.offsetParent : null const observer = parent ? new ResizeObserver(keepInside) : null @@ -115,46 +115,52 @@ export function SWViewerPanel({ return () => { window.removeEventListener("resize", keepInside); observer?.disconnect() } }, [open]) - // Focus in once positioned (a hidden panel cannot take focus), back to the opener on close. - const placed = position !== null - useEffect(() => { - if (!open || !autoFocus) return - const previous = document.activeElement as HTMLElement | null - return () => { if (previous?.isConnected) previous.focus({ preventScroll: true }) } - }, [open, autoFocus]) - useEffect(() => { - if (open && autoFocus && placed && !ref.current?.contains(document.activeElement)) ref.current?.focus({ preventScroll: true }) - }, [open, autoFocus, placed]) - if (!open) return null const raise = () => { if (layer !== topLayer) setLayer(++topLayer) } const setCollapsed = (next: boolean) => { if (collapsedProp === undefined) setInternalCollapsed(next); onCollapsedChange?.(next) } + const onTitlebar = (target: EventTarget) => { + const node = target as HTMLElement + return !!node.closest(".hm-sw-property__titlebar") && !node.closest(INTERACTIVE) + } - const onTitlePointerDown = (event: ReactPointerEvent) => { - if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest(INTERACTIVE) || !ref.current || !position) return + const startDrag = (event: ReactPointerEvent) => { + onPointerDown?.(event) + if (!draggable || event.button !== 0 || !position || !onTitlebar(event.target)) return drag.current = { x: event.clientX, y: event.clientY, left: position.left, top: position.top } event.currentTarget.setPointerCapture(event.pointerId) event.currentTarget.dataset.dragging = "" } - const onTitlePointerMove = (event: ReactPointerEvent) => { - const start = drag.current - if (!start || !ref.current) return - setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, ref.current, strategy)) + const moveDrag = (event: ReactPointerEvent) => { + onPointerMove?.(event) + const start = drag.current, panel = element() + if (!start || !panel) return + setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, panel, strategy)) } - const onTitlePointerUp = (event: ReactPointerEvent) => { + const endDrag = (event: ReactPointerEvent) => { + onPointerUp?.(event) if (!drag.current) return drag.current = null delete event.currentTarget.dataset.dragging if (positionRef.current) onPositionChange?.(positionRef.current) } + const toggleByTitle = (event: ReactMouseEvent) => { + 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) => { - if (event.button !== 0 || !ref.current) return + const panel = element() + if (event.button !== 0 || !panel) return event.preventDefault() - resize.current = { x: event.clientX, y: event.clientY, width: ref.current.offsetWidth, height: ref.current.offsetHeight } + event.stopPropagation() + resize.current = { x: event.clientX, y: event.clientY, width: panel.offsetWidth, height: panel.offsetHeight } event.currentTarget.setPointerCapture(event.pointerId) } const onGripPointerMove = (event: ReactPointerEvent) => { - const start = resize.current, panel = ref.current + event.stopPropagation() + const start = resize.current, panel = element() if (!start || !panel || !position) return const box = bounds(panel, strategy) // Min size, 90% of the bounds, and never past the bounds edge (10px) from the current corner. @@ -164,73 +170,59 @@ export function SWViewerPanel({ height: Math.round(Math.max(minHeight, Math.min(maxHeight, start.height + event.clientY - start.y))), }) } - const onGripPointerUp = () => { + const onGripPointerUp = (event: ReactPointerEvent) => { + event.stopPropagation() if (!resize.current) return resize.current = null - if (ref.current) onSizeChange?.({ width: ref.current.offsetWidth, height: ref.current.offsetHeight }) + const panel = element() + if (panel) onSizeChange?.({ width: panel.offsetWidth, height: panel.offsetHeight }) } - const onPanelKeyDown = (event: KeyboardEvent) => { + const trapFocus = (event: KeyboardEvent) => { onKeyDown?.(event) - if (event.defaultPrevented || event.nativeEvent.isComposing) return - if (event.key === "Escape") { event.stopPropagation(); onClose(); return } - if (modal && event.key === "Tab" && ref.current) { - const focusables = Array.from(ref.current.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])')) - if (!focusables.length) { event.preventDefault(); return } - const first = focusables[0], last = focusables[focusables.length - 1] - if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) { event.preventDefault(); last.focus() } - else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() } - } + const panel = element() + if (!modal || event.key !== "Tab" || event.defaultPrevented || !panel) return + const focusables = Array.from(panel.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])')) + if (!focusables.length) { event.preventDefault(); return } + const first = focusables[0], last = focusables[focusables.length - 1] + if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) { event.preventDefault(); last.focus() } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() } } + const currentWidth = size?.width ?? width const frameStyle: CSSProperties = { position: strategy, left: position?.left ?? 0, top: position?.top ?? 0, zIndex: layer, - width: size?.width ?? width, + width: currentWidth, height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height, minWidth, minHeight: collapsed ? undefined : Math.min(minHeight, typeof height === "number" ? height : minHeight), - visibility: position ? undefined : "hidden", + maxWidth: "90vw", + maxHeight: "90vh", + ["--hm-sw-viewer-panel-width" as string]: `${currentWidth}px`, ...style, } const panel = ( -