Merge branch 'feature/sw-viewer-header' into feature/sw-package

Brings SW viewer mode (SWViewerHeader, SWViewerMenu, SWViewerPanel and panel controls,
ai/VIEWER.md, example H) onto the packaged AI guides. ai/AI_USAGE.md keeps both the
shared-guide link and the viewer row.
This commit is contained in:
shkim4 committed 2026-10-19 17:14:52 +09:00
commit be7a04a847
54 files changed
+4928 -96

No files matched your search

+2 -1
View File
@@ -10,7 +10,8 @@
| [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트(Header·개별 컨트롤·Popup·도구 막대)·Props·Callback·크기·CSS 의존성 |
| [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형·Popup 외형·도구 버튼 |
| [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header, Popup(자유 콘텐츠), 파일 열기 + 도구 막대 |
| [examples/](./examples) | 공개 API만 쓰는 예제 A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증) |
| [examples/](./examples) | 공개 API만 쓰는 예제 A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증), H = 뷰어 모드 |
| [VIEWER.md](./VIEWER.md) | **뷰어 모드 전용**: `SWViewerHeader`(데이터 메뉴·More·외곽선·로고 규칙), `SWViewerMenu`(Dropdown·서브메뉴·우클릭), `SWViewerPanel`(팝업 프레임 + 자유 콘텐츠). 일반 SW 모드는 `SWHeader`·`SWPopup`을 그대로 씁니다 |
## 1. 작업 순서 (필수)
+105
View File
@@ -0,0 +1,105 @@
# @hm-design/sw — 뷰어 모드 (Viewer Header · Popup) 사용 지침
기준: 2026-10-19, 참고 DWG/DXF 뷰어(`dwg-dxf-viewer-hm`) 상단 메뉴·팝업을 같은 해상도(1440 × 900)에서 실측해 패키지 컴포넌트로 옮긴 결과입니다. 아래 이름은 모두 `from "@hm-design/sw"` 공개 Export이고 CSS는 `@hm-design/sw/styles.css` 한 개입니다. 예제: [examples/ExampleH-ViewerMode.tsx](./examples/ExampleH-ViewerMode.tsx).
## 1. 언제 무엇을 쓰나
| 화면 | 사용 | 쓰지 않음 |
|---|---|---|
| SW 일반 모드 Header | `SWHeader` (49px, Figma 26:8944) | `SWViewerHeader` |
| SW 뷰어 모드 Header | `SWViewerHeader` (52px, 참고 뷰어) | `SWHeader`에 뷰어 메뉴를 억지로 넣기, 직접 만든 툴바 |
| 뷰어 메뉴 Dropdown·서브메뉴·우클릭 메뉴 | `type: "menu"` 항목의 `entries`, 단독은 `SWViewerMenu` | `@hm-design/ui` DropdownMenu를 Header 안에 직접 배치 |
| 뷰어 팝업·설정창·도구 패널·입력 팝업 | `SWViewerPanel` (+ `modal`) | 새 프레임 작성, `SWPopup`(일반 SW 속성창 프레임)을 뷰어 디자인으로 재스타일 |
| 일반 SW 팝업·속성창 | `SWPopup`, `SWHeaderPropertyPanel` (변경 없음) | `SWViewerPanel` |
- 한 화면에는 Header 하나만 둡니다. 모드 전환은 프로그램이 Header 컴포넌트를 바꾸는 방식입니다(Storybook `SW/Viewer › SWHeader vs Viewer Header`). 파일·패널 상태는 프로그램 상태라 Header를 바꿔도 유지됩니다.
- 뷰어 모드 진입·종료, 파일 처리, DWG/DXF 렌더링, 메뉴 명령 실행은 **프로그램 담당**입니다. 패키지는 UI와 공통 인터랙션(열기/닫기, Focus, 키보드, 위치·크기)만 담당합니다.
- 기능이 없는 메뉴는 만들지 않습니다. 연결할 명령이 없으면 항목을 빼거나 `disabled`로 두고 “미연결”로 보고합니다.
## 2. `SWViewerHeader`
로고 면(사선) → 파일명 면 → 객체 수정 면(메뉴 그룹) → 오른쪽 끝 영역(`placement: "end"` 그룹 + `trailing`).
| Props | 설명 |
|---|---|
| `groups: SWViewerHeaderGroup[]` | 메뉴 데이터(순서대로 배치). 그룹: `id`, `label`(접근성 이름·More 구획 제목), `moreLabel?`, `surface?: "plain" \| "field"`(field = 사용자 확정 속성 입력 면), `placement?: "start" \| "end"`, `hidden?`, `items` |
| 항목 `SWViewerHeaderItem` | 공통: `id`, `label`, `icon?`(16px, 프로그램 제공), `title?`, `disabled?`, `hidden?`, `collapseBelow?`(Header 폭 미만이면 More로 이동), `iconOnlyBelow?`(미만이면 아이콘만), `iconOnly?`, `moreLabel?` |
| `type: "button"` | `onSelect` |
| `type: "toggle"` | `pressed`, `onPressedChange` (패널 열기/닫기 등) |
| `type: "menu"` | `entries: SWViewerMenuEntry[]`, `menuWidth?`, `menuAlign?: "start" \| "end"` |
| `type: "tabs"` | `value`, `options: { value, label, count?, title?, disabled? }[]`, `onValueChange` (Model / Layout) |
| `type: "switch"` | `checked`, `onCheckedChange` (2.5D 등, 위 11px 캡션) |
| `type: "custom"` | `render()`, `menuEntries?`(More에 넣을 항목) |
| `onCommand(command)` | 모든 명령을 한 곳에서 받습니다: `{ id, itemId, value? }`. 항목 자체 Callback 다음에 호출 |
| `documentTitle`, `documentPlaceholder`, `documentTooltip`, `document?` | 파일명 면 |
| `logo?`, `logoSrc?`, `logoAlt?`, `onHomeClick?` | `SWHeader`와 같은 로고 Props. **주지 않으면 아무것도 그리지 않습니다**(“HM” 문자·기본 아이콘 없음, 로고 면은 사선 끝 24px만). 주면 72px 면(로고가 더 넓으면 그만큼), 높이 최대 24px |
| `trailing?: ReactNode` | 오른쪽 끝 자유 영역: `SWViewerStatus`, `SWViewerHeaderButton` |
| `moreLabel?`, `toolbarLabel?`, `<header>` 속성, `ref` | |
- **반응형(참고 뷰어와 동일):** 항목별 `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), 아이콘 `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`
`entries`: `item`(`icon?`, `shortcut?`, `onSelect`), `checkbox`(`checked`, `onCheckedChange`), `radio`(`checked`, `onSelect`), `submenu`(`entries` — 다단계), `label`, `separator`. 공통 `disabled?`, `hidden?`, `keepOpen?`.
- Header의 `type: "menu"`가 이 Dropdown을 엽니다. 다른 위치(도면 우클릭 등)는 `SWViewerMenu`를 `open` / `onOpenChange` + `anchorRef`(버튼 아래 4px) 또는 `anchorPoint`(포인터 위치)로 씁니다. `surface="beige"`는 More 면, `initialFocus`는 `first`(키보드 열기) / `menu`(클릭 열기).
- 키보드: Enter·Space·ArrowDown 열기, ↑↓ Home End 이동, → 서브메뉴 열기, ← 한 단계 닫기, Escape 닫고 Trigger로 Focus 복귀, Tab·바깥 클릭 닫기. 화면 끝에서는 위/왼쪽으로 뒤집혀 화면 안에 남습니다.
## 4. `SWViewerPanel` — 팝업 프레임 + 자유 콘텐츠
프레임은 **공통 SW 팝업 `SWPopup`과 같은 컴포넌트·DOM**입니다(30px 제목 막대·Figma 제목 아트, #FFC600 700 제목 + 검정 1px 외곽선, SW 닫기 버튼, 1px #A67D03 테두리, 반경 4, #E4DBC9 면). 뷰어는 여기에 드래그·모서리 크기 조절·접기·경계 유지·앞으로 가져오기·모달을 더합니다. 314px가 아닌 폭에서도 제목 아트 곡선은 닫기 버튼에 붙어 있고 왼쪽 평면이 늘어납니다. 내용은 `children`·`footer`가 담당합니다. 일반 SW 팝업은 `SWPopup`(`titleActions?`, `footer?` 추가)을 그대로 씁니다.
| Props | 설명 |
|---|---|
| `title`, `open`, `onClose`, `closeLabel?` | 필수는 `title`·`open`·`onClose`. 제목 아이콘은 없습니다(SW 팝업 디자인) |
| `children` | 스크롤 본문. `SWViewerSection`(카드/plain, 12px 제목), `SWViewerField`(라벨 + 컨트롤, `inline` / `stacked` + `below`), `SWViewerSwitch`, `SWViewerSlider`, `SWViewerInput`, `SWViewerButton`, `SWViewerBadge`, `SWViewerHint`, 프로그램 목록·표 등 무엇이든 |
| `footer?` | 하단 버튼 영역(본문 스크롤과 무관하게 고정). `SWViewerButton variant="outline" \| "primary"`. `tone="green"` = 기본은 그대로, Hover는 primary(확인) 버튼 Hover와 같은 토큰 `--hm-sw-viewer-hover`, 누르는 동안 primary 면 `--hm-sw-viewer-panel-primary` (예: 레이어 전체 켜기 / 전체 끄기) |
| `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 안 |
| `modal?` | 10% 검정 배경, Focus Trap, 가운데 배치(입력·확인 팝업) |
| `autoFocus?` | 열 때 패널로 Focus, 닫을 때 원래 위치로 복귀(기본 true) |
- Escape는 Focus가 패널 안에 있을 때 그 패널만 닫습니다. 비모달 패널은 바깥 클릭으로 닫히지 않습니다(참고 뷰어와 동일, 도구 창). 마지막으로 누른 패널이 맨 앞으로 옵니다.
- **콘텐츠 교체:** 같은 프레임에 다른 `children`을 넣습니다(레이어 목록 / 옵션 / 속성 / 입력 폼 — Storybook `Popup Content Variants`). 프레임을 복사하거나 `hm-sw-viewer-*` class로 새 팝업을 만들지 않습니다.
## 5. 올바른 사용
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
import "@hm-design/sw/styles.css"
const groups: SWViewerHeaderGroup[] = [
{ id: "view", label: "화면 보기", surface: "field", items: [{ type: "button", id: "fit", label: "전체 보기", icon: <FitIcon />, onSelect: fit }] },
{ id: "panels", label: "패널", placement: "end", items: [{ type: "toggle", id: "options", label: "설정", icon: <OptionsIcon />, pressed: open, onPressedChange: setOpen, collapseBelow: 900 }] },
]
<SWViewerHeader documentTitle={fileName} groups={groups} onCommand={runCommand} />
<SWViewerPanel title="옵션" open={open} onClose={() => setOpen(false)}>
<SWViewerSection title="표시">
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={grid} onCheckedChange={setGrid} />}</SWViewerField>
</SWViewerSection>
</SWViewerPanel>
```
## 6. 잘못된 사용
| 하지 말 것 | 이유 / 대신 |
|---|---|
| `<SWViewerHeader logo="HM" />`처럼 로고가 없다고 임의 문자·아이콘 넣기 | 로고는 프로그램이 정합니다. 없으면 생략 |
| 뷰어 Header를 `div` + `button`으로 다시 만들기, `hm-sw-viewer-*` class를 소비 코드에 붙이기 | 데이터(`groups`)와 `custom` / `trailing`으로 구성 |
| Header 버튼 글자에 `-webkit-text-stroke`·`border`로 외곽선 만들기 | 외곽선은 패키지 토큰이 이미 적용합니다 |
| 메뉴 색·간격을 `#123456`, `margin` 등 임의 값으로 덮기 | 토큰 `--hm-sw-viewer-*` / `--hm-sw-header-*`만 `style`로 조정(기본값 유지 권장) |
| 존재하지 않는 Props 추측(`items`, `content`, `renderBody` …) | 위 표와 `dist/index.d.ts`가 전부 |
| 일반 모드 화면에서 `SWViewerHeader` 사용, 뷰어에서 `SWHeader` 재스타일 | 모드별 Header를 바꿔 끼웁니다 |
| 기능 없는 메뉴를 가짜 동작으로 연결 | 항목 제외 또는 `disabled` + 미연결 보고 |
## 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 닫기 추가, 로고 기본 표시 없음(사용자 결정).
@@ -0,0 +1,85 @@
// Example H — viewer mode: SWViewerHeader (data-driven menu) + SWViewerPanel popups with free content.
// Only public components. The program owns the state and the commands; the package owns the look and the
// open / close / focus / keyboard / drag behavior. Icons are the program's own (16px, currentColor).
import { useState } from "react"
import {
SWViewerButton, SWViewerField, SWViewerHeader, SWViewerHeaderButton, SWViewerHint, SWViewerInput, SWViewerPanel,
SWViewerSection, SWViewerSlider, SWViewerStatus, SWViewerSwitch,
type SWViewerHeaderGroup,
} from "@hm-design/sw"
const Icon = ({ d }: { d: string }) => (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>
)
const icons = {
open: <Icon d="M3 7h6l2 2h10v10H3z" />,
fit: <Icon d="M3 8V3h5M16 3h5v5M21 16v5h-5M8 21H3v-5" />,
layers: <Icon d="M12 3l9 5-9 5-9-5 9-5zM3 13l9 5 9-5" />,
options: <Icon d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0" />,
info: <Icon d="M12 8h.01M11 12h1v5h1M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z" />,
}
export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => void }) {
const [fileName, setFileName] = useState<string | null>(null)
const [space, setSpace] = useState("model")
const [depth, setDepth] = useState(false)
const [panels, setPanels] = useState({ layers: false, options: false })
const [grid, setGrid] = useState(true)
const [zoomSpeed, setZoomSpeed] = useState(1)
const [about, setAbout] = useState(false)
const groups: SWViewerHeaderGroup[] = [
{ id: "file", label: "파일", items: [
{ type: "button", id: "open", label: "파일 열기", icon: icons.open, iconOnlyBelow: 560 },
{ type: "menu", id: "samples", label: "샘플", moreLabel: "샘플 도면", collapseBelow: 1180, entries: [
{ type: "item", id: "sample-a", label: "기본 도면 · DWG", onSelect: () => setFileName("BasicSample.dwg") },
{ type: "submenu", id: "recent", label: "최근 도면", entries: [{ type: "item", id: "recent-1", label: "simple.dxf", onSelect: () => setFileName("simple.dxf") }] },
] },
] },
{ id: "space", label: "도면 공간", surface: "field", hidden: !fileName, items: [
{ type: "tabs", id: "space", label: "모델과 배치 선택", collapseBelow: 1180, value: space, onValueChange: setSpace,
options: [{ value: "model", label: "Model", count: 1200 }, { value: "layout", label: "Layout", count: 40 }] },
] },
{ id: "view", label: "화면 보기", surface: "field", items: [
{ type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, disabled: !fileName },
{ type: "switch", id: "depth", label: "2.5D", checked: depth, collapseBelow: 1180, onCheckedChange: setDepth },
] },
{ id: "panels", label: "패널", placement: "end", items: [
{ type: "toggle", id: "layers", label: "레이어", icon: icons.layers, pressed: panels.layers, collapseBelow: 900, onPressedChange: layers => setPanels({ ...panels, layers }) },
{ type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: panels.options, collapseBelow: 900, onPressedChange: options => setPanels({ ...panels, options }) },
] },
]
return (
<>
{/* No logo prop: nothing is drawn on the cap (no "HM" text). Pass logoSrc / logo to show the program's logo. */}
<SWViewerHeader documentTitle={fileName} groups={groups}
onCommand={command => onEvent?.(`${command.itemId}/${command.id}${command.value === undefined ? "" : `=${String(command.value)}`}`)}
trailing={<>
<SWViewerStatus state={fileName ? "ready" : "idle"} />
<SWViewerHeaderButton iconOnly icon={icons.info} label="정보" onClick={() => setAbout(true)} />
</>} />
<SWViewerPanel title="레이어" open={panels.layers} onClose={() => setPanels({ ...panels, layers: false })}>
<SWViewerSection surface="plain" aria-label="레이어 목록">
<SWViewerHint>프로그램의 레이어 목록을 여기에 둡니다.</SWViewerHint>
</SWViewerSection>
</SWViewerPanel>
<SWViewerPanel title="옵션" open={panels.options} onClose={() => setPanels({ ...panels, options: false })} width={340} height="auto" inset={{ side: 356 }}
footer={<SWViewerButton variant="primary" onClick={() => { onEvent?.("options:apply"); setPanels({ ...panels, options: false }) }}>적용</SWViewerButton>}>
<SWViewerSection title="표시">
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={grid} onCheckedChange={setGrid} />}</SWViewerField>
<SWViewerField label="줌 속도" layout="stacked" below={<SWViewerSlider aria-label="줌 속도" value={zoomSpeed} min={0.2} max={3} step={0.1} onValueChange={setZoomSpeed} />}>
{id => <SWViewerInput id={id} type="number" min={0.2} max={3} step={0.1} value={zoomSpeed} onChange={event => setZoomSpeed(Number(event.currentTarget.value))} />}
</SWViewerField>
</SWViewerSection>
</SWViewerPanel>
<SWViewerPanel modal title="정보" open={about} onClose={() => setAbout(false)} width={360} height="auto" resizable={false} collapsible={false}
footer={<SWViewerButton variant="primary" onClick={() => setAbout(false)}>확인</SWViewerButton>}>
<SWViewerHint>모듈 구성과 라이선스 고지는 프로그램이 제공합니다.</SWViewerHint>
</SWViewerPanel>
</>
)
}
@@ -17,6 +17,10 @@ export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "titl
autoFocus?: boolean
/** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */
anchorRef?: RefObject<HTMLElement | null>
/** Extra title-bar buttons, before the close button (e.g. collapse, reset). Default none. */
titleActions?: ReactNode
/** Area after the scrolling body (e.g. buttons). Stays visible while the body scrolls. Default none. */
footer?: ReactNode
}
/**
@@ -24,8 +28,8 @@ export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "titl
* content up to the viewport (the body scrolls). Non-modal (no dim, no portal, no focus trap); the host decides
* when it is shown and where (`style`, or `anchorRef`). Wrap it in <SWTheme> outside SWHeader.
*/
export function SWPopup({ closeLabel = "닫기", ...props }: SWPopupProps) {
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" {...props} />
export function SWPopup({ closeLabel = "닫기", footer, ...props }: SWPopupProps) {
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" after={footer} {...props} />
}
export interface SWPopupSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
@@ -0,0 +1,170 @@
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(" ")
export interface SWViewerSwitchProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked: boolean
onCheckedChange?: (checked: boolean) => void
}
/**
* 36 x 20 switch. Its face follows where it is placed: dark header face (off #0A221D, on #FFC600) inside
* SWViewerHeader, beige panel face (off #9A9284, on #266651) inside SWViewerPanel.
*/
export const SWViewerSwitch = forwardRef<HTMLButtonElement, SWViewerSwitchProps>(function SWViewerSwitch(
{ checked, onCheckedChange, className, onClick, ...rest }, ref,
) {
return (
<button ref={ref} type="button" role="switch" aria-checked={checked} data-state={checked ? "checked" : "unchecked"}
className={cx("hm-sw-viewer-switch", className)}
onClick={event => { onClick?.(event); if (!event.defaultPrevented) onCheckedChange?.(!checked) }} {...rest}>
<span className="hm-sw-viewer-switch__thumb" aria-hidden="true" />
</button>
)
})
export type SWViewerStatusState = "idle" | "loading" | "ready" | "error"
export interface SWViewerStatusProps extends HTMLAttributes<HTMLDivElement> {
state: SWViewerStatusState
/** Short visible text. Default per state: 준비 / 불러오는 중 / 도면 열림 / 불러오기 실패. */
label?: string
/** Longer detail for screen readers and the tooltip. */
detail?: string
}
const statusLabel: Record<SWViewerStatusState, string> = { idle: "준비", loading: "불러오는 중", ready: "도면 열림", error: "불러오기 실패" }
const StatusIcon = ({ state }: { state: SWViewerStatusState }) => state === "idle" ? null : (
<svg className={state === "loading" ? "hm-sw-viewer-status__spin" : undefined} viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
{state === "loading" && <path d="M14 8a6 6 0 1 1-6-6" />}
{state === "ready" && <path d="M3.5 8.5l3 3 6-7" />}
{state === "error" && <><circle cx="8" cy="8" r="6" /><path d="M8 5v3.5M8 11h.01" /></>}
</svg>
)
/** Load state of the viewer header (reference status: muted 11px text, icon; text hides below 560px). */
export function SWViewerStatus({ state, label, detail, className, ...rest }: SWViewerStatusProps) {
return (
<div role="status" className={cx("hm-sw-viewer-status", className)} data-state={state} title={detail} {...rest}>
{state !== "idle" && <span className="hm-sw-viewer-status__icon"><SWViewerIcon><StatusIcon state={state} /></SWViewerIcon></span>}
<span className="hm-sw-viewer-status__label">{label ?? statusLabel[state]}</span>
{detail && <span className="hm-sw-viewer-sr-only">: {detail}</span>}
</div>
)
}
export interface SWViewerButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/**
* Panel buttons. `outline` (default): reference xs outline button (24px, white, #123328 border).
* `primary`: the same size filled with #123328 (footer confirm). `ghost`: icon button of the panel title bar.
*/
variant?: "outline" | "primary" | "ghost"
/** `green`: the default look stays; hover and press turn the face HM green with white text. Default none. */
tone?: "green"
icon?: ReactNode
}
export const SWViewerButton = forwardRef<HTMLButtonElement, SWViewerButtonProps>(function SWViewerButton(
{ variant = "outline", tone, icon, children, className, type = "button", ...rest }, ref,
) {
return (
<button ref={ref} type={type} className={cx("hm-sw-viewer-button", className)} data-variant={variant} data-tone={tone} {...rest}>
{icon && <span className="hm-sw-viewer-button__icon" aria-hidden="true">{icon}</span>}
{children}
</button>
)
})
export type SWViewerInputProps = InputHTMLAttributes<HTMLInputElement>
/** 28px panel input (#F5F0E5 face, #9A9284 border, 12px). Any native input type. */
export const SWViewerInput = forwardRef<HTMLInputElement, SWViewerInputProps>(function SWViewerInput({ className, ...rest }, ref) {
return <input ref={ref} className={cx("hm-sw-viewer-input", className)} {...rest} />
})
export interface SWViewerSliderProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
value: number
min: number
max: number
step?: number
onValueChange?: (value: number) => void
}
/** Panel slider (6px #E0DACE track, #266651 range, 18px white thumb with #266651 ring). Native range input. */
export const SWViewerSlider = forwardRef<HTMLInputElement, SWViewerSliderProps>(function SWViewerSlider(
{ value, min, max, step = 1, onValueChange, className, style, ...rest }, ref,
) {
const percent = max > min ? ((Math.min(max, Math.max(min, value)) - min) / (max - min)) * 100 : 0
return (
<input ref={ref} type="range" className={cx("hm-sw-viewer-slider", className)} value={value} min={min} max={max} step={step}
style={{ ...style, ["--hm-sw-viewer-slider-percent" as string]: `${percent}%` }}
onChange={event => onValueChange?.(Number(event.currentTarget.value))} {...rest} />
)
})
export interface SWViewerFieldProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
label: string
/** Control id for the label. Generated when omitted and `children` is a function. */
htmlFor?: string
/**
* `inline` (default): label left, control right (switch rows). `stacked`: label + control row, then `below`
* (slider rows: number input beside the label, slider underneath).
*/
layout?: "inline" | "stacked"
/** Unit after the control. */
unit?: string
/** Stacked layout: content under the label row (a slider). */
below?: ReactNode
disabled?: boolean
children: ReactNode | ((id: string) => ReactNode)
}
/** Label + control row of the viewer panel (reference option rows). */
export function SWViewerField({ label, htmlFor, layout = "inline", unit, below, disabled, children, className, ...rest }: SWViewerFieldProps) {
const generated = useId()
const id = htmlFor ?? generated
return (
<div className={cx("hm-sw-viewer-field", className)} data-layout={layout} data-disabled={disabled || undefined} {...rest}>
<div className="hm-sw-viewer-field__row">
<label className="hm-sw-viewer-field__label" htmlFor={id}>{label}</label>
<div className="hm-sw-viewer-field__control">
{typeof children === "function" ? children(id) : children}
{unit && <span className="hm-sw-viewer-field__unit">{unit}</span>}
</div>
</div>
{below}
</div>
)
}
export interface SWViewerBadgeProps extends HTMLAttributes<HTMLSpanElement> { children: ReactNode }
/** Count badge of panel lists (12px 500, #F4F1EA face). */
export function SWViewerBadge({ className, ...rest }: SWViewerBadgeProps) {
return <span className={cx("hm-sw-viewer-badge", className)} {...rest} />
}
export interface SWViewerSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
/** 12px 700 heading (reference panel h4). Also the section's accessible name. */
title?: string
/** `card` (default): the inset #F5F0E5 card. `plain`: no face (lists). */
surface?: "card" | "plain"
children?: ReactNode
}
/** A content group of SWViewerPanel. Children are free (fields, lists, any component). */
export function SWViewerSection({ title, surface = "card", children, className, ...rest }: SWViewerSectionProps) {
return (
<section className={cx("hm-sw-viewer-section", className)} data-surface={surface} aria-label={rest["aria-label"] ?? title} {...rest}>
{title && <h4 className="hm-sw-viewer-section__title">{title}</h4>}
{children}
</section>
)
}
/** Muted 12px help text of a panel section. */
export function SWViewerHint({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cx("hm-sw-viewer-hint", className)} {...rest} />
}
@@ -0,0 +1,288 @@
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"
export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
/**
* Logo image URL of the program (same prop as SWHeader), max 24px high on the slanted green cap.
* The logo belongs to each program: nothing is rendered when omitted (no default text or icon) and the cap
* shrinks to its slanted edge.
*/
logoSrc?: string
/** Logo element (inline SVG, <img>, ...). Takes precedence over `logoSrc` (same prop as SWHeader). */
logo?: ReactNode
/** Accessible product name (same prop as SWHeader). With `onHomeClick` the button is "<logoAlt> 홈". */
logoAlt?: string
/** Makes the logo cap a button (e.g. back to the normal SW mode). Same prop as SWHeader. */
onHomeClick?: () => void
/** Open file name. `null` / empty shows `documentPlaceholder`. */
documentTitle?: string | null
/** Default `열린 도면 없음`. */
documentPlaceholder?: string
/** Tooltip of the file name. Default: the name, or `파일 열기 또는 샘플을 선택하세요`. */
documentTooltip?: string
/** Replaces the file-name text (the dark document well stays). */
document?: ReactNode
/** Menu data, in order. Groups with `placement="end"` form the trailing action area. */
groups: SWViewerHeaderGroup[]
/** Free content after the trailing groups (status, info button, ...). */
trailing?: ReactNode
/** Every command of the header and its dropdowns (after the item's own callback). */
onCommand?: (command: SWViewerCommand) => void
/** Accessible name of the More button. Default `추가 도구`. */
moreLabel?: string
/** Accessible name of the object surface. Default `도면 작업 도구`. */
toolbarLabel?: string
}
const MoreIcon = () => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor"><circle cx="3" cy="8" r="1.4" /><circle cx="8" cy="8" r="1.4" /><circle cx="13" cy="8" r="1.4" /></svg>
)
const ChevronDown = () => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
const isShown = (item: { hidden?: boolean; collapseBelow?: number }, width: number) => !item.hidden && !(item.collapseBelow !== undefined && width < item.collapseBelow)
/** More entries of the collapsed items, one section per group (menus and tabs get their own section). */
function moreEntries(groups: SWViewerHeaderGroup[], width: number, command: (c: SWViewerCommand) => void, owners: Map<string, string>): SWViewerMenuEntry[] {
const sections: SWViewerMenuEntry[][] = []
// Dropdown entries keep their own callbacks; the More menu reports them to onCommand with their owner item.
const own = (entries: SWViewerMenuEntry[], itemId: string) => entries.forEach(entry => { owners.set(entry.id, itemId); if (entry.type === "submenu") own(entry.entries, itemId) })
for (const group of groups) {
if (group.hidden) continue
const collapsed = group.items.filter(item => !item.hidden && item.collapseBelow !== undefined && width < item.collapseBelow)
let plain: SWViewerMenuEntry[] = []
const flush = () => { if (plain.length) sections.push([{ type: "label", id: `${group.id}:more`, label: group.moreLabel ?? group.label }, ...plain]); plain = [] }
for (const item of collapsed) {
if (item.type === "custom") { if (item.menuEntries?.length) { flush(); own(item.menuEntries, item.id); sections.push(item.menuEntries) } continue }
if (item.type === "menu") {
flush()
own(item.entries, item.id)
sections.push([{ type: "label", id: `${item.id}:more`, label: item.moreLabel ?? item.label },
...item.entries.map(entry => (entry.type === "label" || entry.type === "separator" ? entry : { ...entry, disabled: entry.disabled || item.disabled }))])
} else if (item.type === "tabs") {
flush()
sections.push([{ type: "label", id: `${item.id}:more`, label: item.moreLabel ?? item.label }, ...item.options.map(option => ({
type: "radio" as const, id: `${item.id}:${option.value}`, checked: option.value === item.value, disabled: option.disabled || item.disabled,
label: option.count !== undefined && option.count > 0 ? `${option.label} · ${option.count.toLocaleString()}개` : option.label,
onSelect: () => { item.onValueChange?.(option.value); command({ id: item.id, itemId: item.id, value: option.value }) },
}))])
} else if (item.type === "button") {
plain.push({ type: "item", id: item.id, label: item.label, icon: item.icon, disabled: item.disabled, onSelect: () => { item.onSelect?.(); command({ id: item.id, itemId: item.id }) } })
} else {
const checked = item.type === "toggle" ? item.pressed : item.checked
plain.push({ type: "checkbox", id: item.id, label: item.label, checked, disabled: item.disabled, onCheckedChange: next => {
if (item.type === "toggle") item.onPressedChange?.(next); else item.onCheckedChange?.(next)
command({ id: item.id, itemId: item.id, value: next })
} })
}
}
flush()
}
return sections.flatMap((section, index) => (index ? [{ type: "separator" as const, id: `more-sep-${index}` }, ...section] : section))
}
function MenuButton({ item, iconOnly, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "menu" }>; iconOnly: boolean | "always"; onCommand: (c: SWViewerCommand) => void }) {
const ref = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
const [focus, setFocus] = useState<"first" | "menu">("menu")
const menuId = useId()
const show = (how: "first" | "menu") => { setFocus(how); setOpen(true) }
return (
<>
<button ref={ref} type="button" className="hm-sw-viewer-header__button" data-kind="menu" data-icon-only={iconOnly === "always" ? "always" : iconOnly ? "" : undefined} data-state={open ? "open" : undefined}
aria-haspopup="menu" aria-expanded={open} aria-controls={open ? menuId : undefined} aria-label={iconOnly ? item.label : undefined} title={item.title} disabled={item.disabled}
onClick={() => (open ? setOpen(false) : show("menu"))}
onKeyDown={event => { if (event.key === "ArrowDown" || ((event.key === "Enter" || event.key === " ") && !open)) { event.preventDefault(); show("first") } }}>
{item.icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true"><SWViewerIcon>{item.icon}</SWViewerIcon></span>}
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</span>}
<span className="hm-sw-viewer-header__chevron"><SWViewerIcon><ChevronDown /></SWViewerIcon></span>
</button>
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} label={item.label} initialFocus={focus}
onEntrySelect={entry => onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} />
</>
)
}
function Tabs({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "tabs" }>; onCommand: (c: SWViewerCommand) => void }) {
const select = (value: string) => { item.onValueChange?.(value); onCommand({ id: item.id, itemId: item.id, value }) }
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const keys = ["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"]
if (!keys.includes(event.key)) return
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled)'))
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
if (index < 0) return
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length
buttons[next].focus()
buttons[next].click()
}
const current = item.options.some(option => option.value === item.value) ? item.value : item.options.find(option => !option.disabled)?.value
return (
<div role="radiogroup" aria-label={item.label} className="hm-sw-viewer-header__tabs" onKeyDown={onKeyDown}>
{item.options.map(option => {
const selected = option.value === item.value
return (
<button key={option.value} type="button" role="radio" aria-checked={selected} tabIndex={option.value === current ? 0 : -1}
className="hm-sw-viewer-header__tab" data-state={selected ? "active" : undefined} title={option.title} disabled={item.disabled || option.disabled}
onClick={() => { if (!selected) select(option.value) }}>
{option.label}
{option.count !== undefined && option.count > 0 && <span className="hm-sw-viewer-header__count">{option.count.toLocaleString()}</span>}
</button>
)
})}
</div>
)
}
function SwitchField({ item, onCommand }: { item: Extract<SWViewerHeaderItem, { type: "switch" }>; onCommand: (c: SWViewerCommand) => void }) {
const id = useId()
return (
<div className="hm-sw-viewer-header__switch-field" title={item.title}>
<label htmlFor={id} className="hm-sw-viewer-header__switch-label">{item.label}</label>
<SWViewerSwitch id={id} checked={item.checked} disabled={item.disabled}
onCheckedChange={next => { item.onCheckedChange?.(next); onCommand({ id: item.id, itemId: item.id, value: next }) }} />
</div>
)
}
function Item({ item, width, onCommand }: { item: SWViewerHeaderItem; width: number; onCommand: (c: SWViewerCommand) => void }) {
if (item.type === "custom") return <>{item.render()}</>
const iconOnly = !!item.icon && (item.iconOnly ? "always" : item.iconOnlyBelow !== undefined && width < item.iconOnlyBelow)
if (item.type === "menu") return <MenuButton item={item} iconOnly={iconOnly} onCommand={onCommand} />
if (item.type === "tabs") return <Tabs item={item} onCommand={onCommand} />
if (item.type === "switch") return <SwitchField item={item} onCommand={onCommand} />
const toggle = item.type === "toggle"
return (
<SWViewerHeaderButton label={item.label} icon={item.icon} iconOnly={iconOnly === "always" ? true : iconOnly ? "compact" : false} pressed={toggle ? item.pressed : undefined} title={item.title} disabled={item.disabled}
onClick={() => {
if (toggle) { item.onPressedChange?.(!item.pressed); onCommand({ id: item.id, itemId: item.id, value: !item.pressed }) }
else { item.onSelect?.(); onCommand({ id: item.id, itemId: item.id }) }
}} />
)
}
export interface SWViewerHeaderButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
/** Visible text, or the accessible name when `iconOnly`. */
label: string
/** 16px icon supplied by the program. */
icon?: ReactNode
/**
* `true`: always icon only, the 32 x 40 icon button (info, More). `"compact"`: the 34 x 34 command button shown
* without its label (what `iconOnlyBelow` does). Default false.
*/
iconOnly?: boolean | "compact"
/** Toggle state (aria-pressed, #FFC600 when on). Leave undefined for a command button. */
pressed?: boolean
}
/**
* The viewer header button for the `trailing` slot or a `custom` item (e.g. the info button that opens a modal
* SWViewerPanel). Same look as the data-driven items: transparent, #266651 hover, #FFC600 when pressed.
*/
export const SWViewerHeaderButton = forwardRef<HTMLButtonElement, SWViewerHeaderButtonProps>(function SWViewerHeaderButton(
{ label, icon, iconOnly = false, pressed, className, type = "button", ...rest }, ref,
) {
const kind = pressed === undefined ? "button" : "toggle"
return (
<button ref={ref} type={type} className={`hm-sw-viewer-header__button${className ? ` ${className}` : ""}`} data-kind={kind} data-icon-only={iconOnly === true ? "always" : iconOnly ? "" : undefined}
data-state={pressed === undefined ? undefined : pressed ? "on" : "off"} aria-pressed={pressed} aria-label={iconOnly ? label : undefined} {...rest}>
{icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true"><SWViewerIcon>{icon}</SWViewerIcon></span>}
{!iconOnly && <span className="hm-sw-viewer-header__label">{label}</span>}
</button>
)
})
/**
* SW viewer-mode header (reference DWG/DXF viewer top menu): slanted brand cap → dark file-name well → one green
* object surface holding data-driven menu groups, with a trailing action area. Items move into the More dropdown
* by `collapseBelow` (measured header width, never horizontal scroll). Pure presentation: values are props, every
* action is a callback (`onCommand` plus each item's own). Use SWHeader for the normal SW mode.
*/
export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(function SWViewerHeader(
{
logo, logoSrc, logoAlt, onHomeClick, documentTitle, documentPlaceholder = "열린 도면 없음", documentTooltip, document: documentNode,
groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest
},
forwardedRef,
) {
const rootRef = useRef<HTMLElement | null>(null)
const [width, setWidth] = useState(1920)
const gradientId = `hm-sw-viewer-brand-${useId().replace(/:/g, "")}`
useLayoutEffect(() => {
const root = rootRef.current
if (!root) return
const update = () => setWidth(root.getBoundingClientRect().width)
update()
const observer = new ResizeObserver(update)
observer.observe(root)
return () => observer.disconnect()
}, [])
const command = (value: SWViewerCommand) => onCommand?.(value)
const owners = new Map<string, string>()
const more = moreEntries(groups, width, command, owners)
const moreRef = useRef<HTMLButtonElement>(null)
const [moreOpen, setMoreOpen] = useState(false)
const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu")
const renderGroup = (group: SWViewerHeaderGroup) => {
const items = group.items.filter(item => isShown(item, width))
if (group.hidden || !items.length) return null
return (
<div key={group.id} role="group" aria-label={group.label} className="hm-sw-viewer-header__group" data-surface={group.surface ?? "plain"} data-group={group.id}>
{items.map(item => <Item key={item.id} item={item} width={width} onCommand={command} />)}
</div>
)
}
const start = groups.filter(group => group.placement !== "end")
const end = groups.filter(group => group.placement === "end")
const name = documentTitle || documentPlaceholder
const mark = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
const brandContent = (
<>
<svg className="hm-sw-viewer-header__brand-shape" viewBox="0 0 88 52" preserveAspectRatio="xMaxYMid slice" aria-hidden="true" focusable="false">
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--hm-sw-viewer-brand-top)" />
<stop offset="45%" stopColor="var(--hm-sw-viewer-brand-middle)" />
<stop offset="100%" stopColor="var(--hm-sw-viewer-brand-bottom)" />
</linearGradient>
</defs>
<path d="M 0 0 H 88 L 73 44 Q 70 52 62 52 H 0 Z" fill={`url(#${gradientId})`} />
<path d="M 88 0 L 73 44 Q 70 52 62 52 H 0" fill="none" stroke="var(--hm-sw-viewer-brand-middle)" vectorEffect="non-scaling-stroke" />
</svg>
{mark && <span className="hm-sw-viewer-header__brand-mark" aria-hidden="true">{mark}</span>}
</>
)
return (
<header ref={node => { rootRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node }}
className={`hm-sw-theme hm-sw-viewer-header${className ? ` ${className}` : ""}`} {...rest}>
{onHomeClick
? <button type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} title={logoAlt} onClick={onHomeClick}>{brandContent}</button>
: <div className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} role={logoAlt ? "img" : undefined} aria-label={logoAlt}>{brandContent}</div>}
<div className="hm-sw-viewer-header__document" role="group" aria-label="현재 도면">
{documentNode ?? <span className="hm-sw-viewer-header__document-name" data-empty={!documentTitle || undefined} title={documentTooltip ?? (documentTitle || "파일 열기 또는 샘플을 선택하세요")}>{name}</span>}
</div>
<div className="hm-sw-viewer-header__object" role="toolbar" aria-label={toolbarLabel}>
{start.map(renderGroup)}
{more.length > 0 && <>
<button ref={moreRef} type="button" className="hm-sw-viewer-header__button hm-sw-viewer-header__more" data-icon-only="always" data-state={moreOpen ? "open" : undefined}
aria-label={moreLabel} title="더보기" aria-haspopup="menu" aria-expanded={moreOpen}
onClick={() => { setMoreFocus("menu"); setMoreOpen(!moreOpen) }}
onKeyDown={event => { if (event.key === "ArrowDown") { event.preventDefault(); setMoreFocus("first"); setMoreOpen(true) } }}>
<span className="hm-sw-viewer-header__icon"><SWViewerIcon><MoreIcon /></SWViewerIcon></span>
</button>
<SWViewerMenu entries={more} open={moreOpen} onOpenChange={setMoreOpen} anchorRef={moreRef} align="end" width={260} surface="beige" label={moreLabel} initialFocus={moreFocus}
onEntrySelect={entry => {
const owner = owners.get(entry.id)
if (owner) command({ id: entry.id, itemId: owner, value: entry.type === "checkbox" ? !entry.checked : undefined })
}} />
</>}
{(end.length > 0 || trailing) && <div className="hm-sw-viewer-header__actions">{end.map(renderGroup)}{trailing}</div>}
</div>
</header>
)
})
@@ -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>
)
}
@@ -0,0 +1,226 @@
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"
import type { SWViewerMenuEntry } from "./types"
import "../../theme/sw-viewer.css"
export interface SWViewerMenuProps {
entries: SWViewerMenuEntry[]
open: boolean
onOpenChange: (open: boolean) => void
/** Element the menu opens below (a header button). Focus returns to it on close. */
anchorRef?: RefObject<HTMLElement | null>
/** Opens at a point instead (context menu: the pointer position). */
anchorPoint?: { x: number; y: number }
/** Edge aligned to the anchor. Default `start`. */
align?: "start" | "end"
/** Width in px. Default: its content (min 160). */
width?: number
/** `light` (default): the white dropdown face. `beige`: the More face of the viewer header. */
surface?: "light" | "beige"
/** Accessible name of the menu. */
label?: string
/** Called for every activated entry (after its own callback). */
onEntrySelect?: (entry: SWViewerMenuEntry) => void
/** `first` (default): focus the first entry, as after Enter / ArrowDown. `menu`: focus the list, as after a click. */
initialFocus?: "first" | "menu"
/** Id of the menu element (for the trigger's aria-controls). */
id?: string
className?: string
}
const ITEM_SELECTOR = ':scope > [role="none"] > [role^="menuitem"]:not([aria-disabled="true"])'
function visible(entries: SWViewerMenuEntry[]) {
const shown = entries.filter(entry => !entry.hidden)
// No leading, trailing or doubled separators once hidden entries are gone.
return shown.filter((entry, index) => entry.type !== "separator"
|| (index > 0 && index < shown.length - 1 && shown[index - 1].type !== "separator"))
}
const CheckIcon = () => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
const ChevronRight = () => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M6 3.5l4.5 4.5L6 12.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
interface ListProps {
entries: SWViewerMenuEntry[]
surface: "light" | "beige"
width?: number
label?: string
/** Fixed position computer: receives the list size, returns left/top. */
place: (width: number, height: number) => { left: number; top: number }
/** Activate: run the entry and close everything (unless keepOpen). */
activate: (entry: SWViewerMenuEntry) => void
/** Escape / Tab: close everything. */
closeAll: (returnFocus: boolean) => void
/** ArrowLeft inside a submenu: close only this level. */
closeLevel?: () => void
/** `first`: focus the first entry (keyboard open). `menu`: focus the list itself (pointer open). */
autoFocus: "first" | "menu" | false
id?: string
className?: string
}
function MenuList({ entries, surface, width, label, place, activate, closeAll, closeLevel, autoFocus, id, className }: ListProps) {
const ref = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
const [openSub, setOpenSub] = useState<string | null>(null)
const [focusSub, setFocusSub] = useState(false)
const placeRef = useRef(place)
placeRef.current = place
useLayoutEffect(() => {
const update = () => {
const list = ref.current
if (!list) return
const next = placeRef.current(list.offsetWidth, list.offsetHeight)
setPosition(current => (current && current.left === next.left && current.top === next.top ? current : next))
}
update()
window.addEventListener("resize", update)
window.addEventListener("scroll", update, true)
return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true) }
}, [])
useEffect(() => {
if (!autoFocus || !position) return
const target = autoFocus === "first" ? ref.current?.querySelector<HTMLElement>(ITEM_SELECTOR) : ref.current
target?.focus({ preventScroll: true })
}, [autoFocus, position !== null])
const items = () => Array.from(ref.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// Only this level's own keys; a submenu inside handles (and stops) its own.
if (event.nativeEvent.isComposing) return
const list = items()
const index = list.indexOf(document.activeElement as HTMLElement)
const move = (next: number) => { event.preventDefault(); list[(next + list.length) % list.length]?.focus() }
switch (event.key) {
case "ArrowDown": move(index + 1); break
case "ArrowUp": move(index < 0 ? list.length - 1 : index - 1); break
case "Home": move(0); break
case "End": move(list.length - 1); break
case "ArrowLeft":
if (closeLevel) { event.preventDefault(); event.stopPropagation(); closeLevel() }
break
case "Escape": event.preventDefault(); event.stopPropagation(); closeAll(true); break
case "Tab": closeAll(false); break
default: return
}
event.stopPropagation()
}
const rows: ReactNode[] = visible(entries).map(entry => {
if (entry.type === "separator") return <div key={entry.id} role="separator" className="hm-sw-viewer-menu__separator" />
if (entry.type === "label") return <div key={entry.id} role="presentation" className="hm-sw-viewer-menu__label">{entry.label}</div>
const disabled = !!entry.disabled
const common = {
tabIndex: -1,
"aria-disabled": disabled || undefined,
"data-entry": entry.id,
onPointerMove: (event: { currentTarget: HTMLElement }) => {
if (disabled) return
if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true })
if (entry.type !== "submenu" && openSub) setOpenSub(null)
},
}
if (entry.type === "submenu") {
const expanded = openSub === entry.id
const openThis = (focus: boolean) => { if (disabled) return; setFocusSub(focus); setOpenSub(entry.id) }
return (
<div key={entry.id} role="none" className="hm-sw-viewer-menu__entry">
<button type="button" role="menuitem" className="hm-sw-viewer-menu__item" aria-haspopup="menu" aria-expanded={expanded} data-state={expanded ? "open" : undefined} {...common}
onPointerEnter={() => openThis(false)}
onClick={() => openThis(true)}
onKeyDown={event => { if (event.key === "ArrowRight" || event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); openThis(true) } }}>
<span className="hm-sw-viewer-menu__icon" aria-hidden="true">{entry.icon}</span>
<span className="hm-sw-viewer-menu__text">{entry.label}</span>
<span className="hm-sw-viewer-menu__chevron"><ChevronRight /></span>
</button>
{expanded && <MenuList entries={entry.entries} surface={surface} label={entry.label} autoFocus={focusSub ? "first" : false}activate={activate} closeAll={closeAll}
closeLevel={() => { setOpenSub(null); ref.current?.querySelector<HTMLElement>(`[data-entry="${CSS.escape(entry.id)}"]`)?.focus() }}
place={(w, h) => {
const row = ref.current?.querySelector<HTMLElement>(`[data-entry="${CSS.escape(entry.id)}"]`)?.getBoundingClientRect()
const vw = document.documentElement.clientWidth, vh = window.innerHeight
if (!row) return { left: 0, top: 0 }
// Beside the parent list (its outer edge), first entry level with the row; flips left at the screen edge.
const list = ref.current!.getBoundingClientRect()
const left = list.right + w <= vw - 4 ? list.right : Math.max(4, list.left - w)
return { left, top: Math.max(4, Math.min(row.top - 5, vh - h - 4)) }
}} />}
</div>
)
}
const role = entry.type === "checkbox" ? "menuitemcheckbox" : entry.type === "radio" ? "menuitemradio" : "menuitem"
const checked = entry.type === "item" ? undefined : entry.checked
return (
<div key={entry.id} role="none" className="hm-sw-viewer-menu__entry">
<button type="button" role={role} className="hm-sw-viewer-menu__item" aria-checked={checked} data-kind={entry.type} {...common}
onClick={() => { if (!disabled) activate(entry) }}>
<span className="hm-sw-viewer-menu__icon" aria-hidden="true">
{entry.type === "item" ? entry.icon : checked ? entry.type === "radio" ? <span className="hm-sw-viewer-menu__dot" /> : <CheckIcon /> : null}
</span>
<span className="hm-sw-viewer-menu__text">{entry.label}</span>
{entry.type === "item" && entry.shortcut && <span className="hm-sw-viewer-menu__shortcut">{entry.shortcut}</span>}
</button>
</div>
)
})
return (
<div ref={ref} id={id} role="menu" tabIndex={-1} aria-label={label} aria-orientation="vertical" className={`hm-sw-theme hm-sw-viewer-menu${className ? ` ${className}` : ""}`} data-surface={surface}
style={{ left: position?.left ?? 0, top: position?.top ?? 0, width, visibility: position ? undefined : "hidden" }}
onKeyDown={onKeyDown}>
{rows.length ? rows : <div role="presentation" className="hm-sw-viewer-menu__label">항목 없음</div>}
</div>
)
}
/**
* SW viewer dropdown (reference DWG/DXF viewer menu): fixed overlay below an anchor (or at a point), kept inside
* the viewport. Items, check / radio items, labels, separators and multi-level submenus come from `entries`.
* Keyboard: ArrowUp / ArrowDown / Home / End move, Enter / Space activate, ArrowRight / ArrowLeft open / close a
* submenu, Escape closes and returns focus to the anchor, Tab closes. A click outside closes.
*/
export function SWViewerMenu({ entries, open, onOpenChange, anchorRef, anchorPoint, align = "start", width, surface = "light", label, onEntrySelect, initialFocus = "first", id, className }: SWViewerMenuProps) {
const rootRef = useRef<HTMLDivElement>(null)
const changeRef = useRef(onOpenChange)
changeRef.current = onOpenChange
useEffect(() => {
if (!open) return
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (rootRef.current?.contains(target) || anchorRef?.current?.contains(target)) return
changeRef.current(false)
}
document.addEventListener("pointerdown", onPointerDown, true)
return () => document.removeEventListener("pointerdown", onPointerDown, true)
}, [open, anchorRef])
if (!open) return null
const closeAll = (returnFocus: boolean) => {
onOpenChange(false)
if (returnFocus) anchorRef?.current?.focus()
}
const activate = (entry: SWViewerMenuEntry) => {
if (entry.type === "item" || entry.type === "radio") entry.onSelect?.()
else if (entry.type === "checkbox") entry.onCheckedChange?.(!entry.checked)
onEntrySelect?.(entry)
if (!("keepOpen" in entry && entry.keepOpen)) closeAll(true)
}
const place = (w: number, h: number) => {
const vw = document.documentElement.clientWidth, vh = window.innerHeight
const box = anchorPoint ? { left: anchorPoint.x, right: anchorPoint.x, top: anchorPoint.y, bottom: anchorPoint.y }
: anchorRef?.current?.getBoundingClientRect() ?? { left: 0, right: 0, top: 0, bottom: 0 }
const start = align === "end" ? box.right - w : box.left
const left = Math.max(4, Math.min(start, vw - w - 4))
// 4px under a button; exactly at the pointer for a context menu.
const gap = anchorPoint ? 0 : 4
const below = box.bottom + gap
const top = below + h > vh - 4 && box.top - h - gap >= 4 ? box.top - h - gap : Math.max(4, Math.min(below, vh - h - 4))
return { left, top }
}
return (
<div ref={rootRef} className="hm-sw-viewer-menu-root">
<MenuList entries={entries} surface={surface} width={width} label={label} place={place} activate={activate} closeAll={closeAll} autoFocus={initialFocus} id={id} className={className} />
</div>
)
}
@@ -0,0 +1,228 @@
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"
import { SWPopup } from "../sw-popup/SWPopup"
import "../../theme/sw-viewer.css"
export type SWViewerPanelPlacement = "top-end" | "top-start" | "center"
export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
/** Title (SWPopup title: 700, #FFC600, black 1px outline, ellipsis). Also the accessible name. */
title: string
/** Shown when true. Unmounted otherwise (no hidden DOM). */
open: boolean
/** 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"`). */
headerActions?: ReactNode
/** Free body content: SWViewerSection / SWViewerField / lists / any component. The body scrolls. */
children?: ReactNode
/** Bottom button area (e.g. 취소 / 적용). Stays visible while the body scrolls. */
footer?: ReactNode
/** Initial size. Default 314 (the SWPopup width) x 420. `height: "auto"` follows the content. */
width?: number
height?: number | "auto"
minWidth?: number
minHeight?: number
/** Initial position: a point, or a placement inside the bounds. Default `top-end`. */
defaultPosition?: { left: number; top: number } | SWViewerPanelPlacement
/** Placement insets in px. Default `{ top: 56, side: 24 }` (below the 52px viewer header). */
inset?: { top?: number; side?: number }
/** `fixed` (default): the viewport. `absolute`: the nearest positioned ancestor (embedded preview). */
strategy?: "fixed" | "absolute"
/** Drag by the title bar. Default true. */
draggable?: boolean
/** Corner grip resize (min / max size, kept inside the bounds). Default true. */
resizable?: boolean
/** Collapse button and title double click. Default true. */
collapsible?: boolean
defaultCollapsed?: boolean
collapsed?: boolean
onCollapsedChange?: (collapsed: boolean) => void
/** Dim backdrop, focus trap and centered default (input / confirm popups). Default false. */
modal?: boolean
/** Focus the panel when it opens; focus returns to the previous element when it closes. Default true. */
autoFocus?: boolean
onPositionChange?: (position: { left: number; top: number }) => void
/** After a corner resize. */
onSizeChange?: (size: { width: number; height: number }) => void
}
let topLayer = 40
const EDGE = 10
const INTERACTIVE = "button, input, select, textarea, a, label, [data-panel-no-drag]"
const Chevron = ({ down }: { down: boolean }) => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d={down ? "M4 6l4 4 4-4" : "M6 4l4 4-4 4"} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
function bounds(element: HTMLElement, 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 }
}
function clamp(left: number, top: number, element: HTMLElement, strategy: "fixed" | "absolute") {
const box = bounds(element, strategy)
return {
left: Math.round(Math.min(Math.max(left, EDGE), Math.max(EDGE, box.width - element.offsetWidth - EDGE))),
top: Math.round(Math.min(Math.max(top, EDGE), Math.max(EDGE, box.height - element.offsetHeight - EDGE))),
}
}
/**
* 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, 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, id: idProp, className, style, onKeyDown,
onPointerDownCapture, onFocusCapture, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, ...rest
}: SWViewerPanelProps) {
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)
const collapsed = collapsible && (collapsedProp ?? internalCollapsed)
const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
// Size after a corner resize (null: the width / height props).
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
const resize = useRef<{ x: number; y: number; width: number; height: number } | null>(null)
const positionRef = useRef(position)
positionRef.current = position
const placement = defaultPosition ?? (modal ? "center" : "top-end")
// 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 = element()
if (!panel) return
const box = bounds(panel, strategy)
const top = inset?.top ?? 56, side = inset?.side ?? 24
const start = typeof placement === "object" ? placement
: 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, 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
if (parent) observer?.observe(parent)
return () => { window.removeEventListener("resize", keepInside); observer?.disconnect() }
}, [open])
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 startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
onPointerDown?.(event)
if (!draggable || event.button !== 0 || !position || !onTitlebar(event.target)) return
drag.current = { x: event.clientX, y: event.clientY, left: position.left, top: position.top }
event.currentTarget.setPointerCapture(event.pointerId)
event.currentTarget.dataset.dragging = ""
}
const moveDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
onPointerMove?.(event)
const start = drag.current, panel = element()
if (!start || !panel) return
setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, panel, strategy))
}
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
onPointerUp?.(event)
if (!drag.current) return
drag.current = null
delete event.currentTarget.dataset.dragging
if (positionRef.current) onPositionChange?.(positionRef.current)
}
const toggleByTitle = (event: ReactMouseEvent<HTMLDivElement>) => {
onDoubleClick?.(event)
// Pointer capture (drag) retargets the click to the panel root, so test what is under the pointer.
const under = document.elementFromPoint(event.clientX, event.clientY)
if (collapsible && under && onTitlebar(under)) setCollapsed(!collapsed)
}
const onGripPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
const panel = element()
if (event.button !== 0 || !panel) return
event.preventDefault()
event.stopPropagation()
resize.current = { x: event.clientX, y: event.clientY, width: panel.offsetWidth, height: panel.offsetHeight }
event.currentTarget.setPointerCapture(event.pointerId)
}
const onGripPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
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.
const maxWidth = Math.min(box.width * 0.9, box.width - position.left - EDGE), maxHeight = Math.min(box.height * 0.9, box.height - position.top - EDGE)
setSize({
width: Math.round(Math.max(minWidth, Math.min(maxWidth, start.width + event.clientX - start.x))),
height: Math.round(Math.max(minHeight, Math.min(maxHeight, start.height + event.clientY - start.y))),
})
}
const onGripPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation()
if (!resize.current) return
resize.current = null
const panel = element()
if (panel) onSizeChange?.({ width: panel.offsetWidth, height: panel.offsetHeight })
}
const trapFocus = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
const panel = element()
if (!modal || event.key !== "Tab" || event.defaultPrevented || !panel) return
const focusables = Array.from(panel.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])'))
if (!focusables.length) { event.preventDefault(); return }
const first = focusables[0], last = focusables[focusables.length - 1]
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
const currentWidth = size?.width ?? width
const frameStyle: CSSProperties = {
position: strategy,
left: position?.left ?? 0,
top: position?.top ?? 0,
zIndex: layer,
width: currentWidth,
height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height,
minWidth,
minHeight: collapsed ? undefined : Math.min(minHeight, typeof height === "number" ? height : minHeight),
maxWidth: "90vw",
maxHeight: "90vh",
["--hm-sw-viewer-panel-width" as string]: `${currentWidth}px`,
...style,
}
const panel = (
<SWPopup {...rest} id={id} title={title} onClose={onClose} closeLabel={closeLabel} autoFocus={autoFocus}
className={`hm-sw-theme hm-sw-viewer-panel${className ? ` ${className}` : ""}`} style={frameStyle}
aria-modal={modal}
data-collapsed={collapsed || undefined} data-modal={modal || undefined} data-draggable={draggable || undefined}
onKeyDown={trapFocus}
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}
onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={endDrag} onDoubleClick={toggleByTitle}
titleActions={(headerActions || collapsible) && <div className="hm-sw-viewer-panel__actions">
{headerActions}
{collapsible && <button type="button" className="hm-sw-viewer-panel__icon-button" aria-label={collapsed ? "펼치기" : "접기"} title={collapsed ? "펼치기" : "접기"} aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}><Chevron down={!collapsed} /></button>}
</div>}
footer={<>
{footer && !collapsed && <div className="hm-sw-viewer-panel__footer">{footer}</div>}
{resizable && !collapsed && <div className="hm-sw-viewer-panel__resize" aria-hidden="true" data-panel-no-drag=""
onPointerDown={onGripPointerDown} onPointerMove={onGripPointerMove} onPointerUp={onGripPointerUp} onPointerCancel={onGripPointerUp} />}
</>}>
{collapsed ? null : children}
</SWPopup>
)
return modal ? <><div className="hm-sw-viewer-panel-backdrop" style={{ position: strategy, zIndex: layer }} aria-hidden="true" />{panel}</> : panel
}
@@ -0,0 +1,10 @@
export { SWViewerHeader, SWViewerHeaderButton, type SWViewerHeaderProps, type SWViewerHeaderButtonProps } from "./SWViewerHeader"
export { SWViewerMenu, type SWViewerMenuProps } from "./SWViewerMenu"
export { SWViewerIcon, type SWViewerIconProps } from "./SWViewerIcon"
export { SWViewerPanel, type SWViewerPanelProps, type SWViewerPanelPlacement } from "./SWViewerPanel"
export {
SWViewerSection, SWViewerField, SWViewerHint, SWViewerSwitch, SWViewerSlider, SWViewerInput, SWViewerButton, SWViewerBadge, SWViewerStatus,
type SWViewerSectionProps, type SWViewerFieldProps, type SWViewerSwitchProps, type SWViewerSliderProps, type SWViewerInputProps,
type SWViewerButtonProps, type SWViewerBadgeProps, type SWViewerStatusProps, type SWViewerStatusState,
} from "./SWViewerControls"
export type { SWViewerHeaderItem, SWViewerHeaderGroup, SWViewerMenuEntry, SWViewerCommand } from "./types"
@@ -0,0 +1,131 @@
import type { ReactNode } from "react"
/** One entry of an SW viewer dropdown (header menu, More, context menu). Data only: the host runs the command. */
export type SWViewerMenuEntry =
| {
type: "item"
id: string
label: string
/** 16px icon supplied by the program. */
icon?: ReactNode
/** Right-aligned hint, e.g. `F`. */
shortcut?: string
disabled?: boolean
hidden?: boolean
/** Keep the menu open after selecting. Default false. */
keepOpen?: boolean
onSelect?: () => void
}
| {
type: "checkbox"
id: string
label: string
checked: boolean
disabled?: boolean
hidden?: boolean
keepOpen?: boolean
onCheckedChange?: (checked: boolean) => void
}
| {
type: "radio"
id: string
label: string
checked: boolean
disabled?: boolean
hidden?: boolean
keepOpen?: boolean
onSelect?: () => void
}
| {
/** Multi-level menu: opens to the side (hover, Enter / Space / ArrowRight; ArrowLeft closes). */
type: "submenu"
id: string
label: string
icon?: ReactNode
disabled?: boolean
hidden?: boolean
entries: SWViewerMenuEntry[]
}
| { type: "label"; id: string; label: string; hidden?: boolean }
| { type: "separator"; id: string; hidden?: boolean }
/** Command event of SWViewerHeader / SWViewerMenu: the one place a program wires every menu action. */
export interface SWViewerCommand {
/** Header item id, or menu entry id for dropdown entries. */
id: string
/** Header item that owns the command (equals `id` for header buttons, toggles, tabs, switches). */
itemId: string
/** New state: `pressed` / `checked` (boolean) or the selected tab value (string). */
value?: boolean | string
}
interface SWViewerHeaderItemBase {
id: string
/** Visible text; also the accessible name (icon-only buttons keep it as aria-label). */
label: string
/** 16px icon supplied by the program. */
icon?: ReactNode
/** Tooltip (title attribute). */
title?: string
disabled?: boolean
/** Removed from the header and from More. */
hidden?: boolean
/** Header width (px) below which the item moves into the More dropdown. */
collapseBelow?: number
/** Header width (px) below which only the icon is shown (label kept as the accessible name). */
iconOnlyBelow?: number
/** Always icon only (e.g. the info button). Needs `icon`. */
iconOnly?: boolean
/** Section title of this item inside More (menu and tabs items only). Default `label`. */
moreLabel?: string
}
export type SWViewerHeaderItem =
| (SWViewerHeaderItemBase & { type: "button"; onSelect?: () => void })
| (SWViewerHeaderItemBase & { type: "toggle"; pressed: boolean; onPressedChange?: (pressed: boolean) => void })
| (SWViewerHeaderItemBase & {
/** Button with a dropdown (chevron). */
type: "menu"
entries: SWViewerMenuEntry[]
/** Dropdown width in px. Default: its content (min 160). */
menuWidth?: number
/** Dropdown edge aligned to the button. Default `start`. */
menuAlign?: "start" | "end"
})
| (SWViewerHeaderItemBase & {
/** Single selection such as Model / Layout (radiogroup, arrow keys move). */
type: "tabs"
value: string | null
options: { value: string; label: string; count?: number; title?: string; disabled?: boolean }[]
onValueChange?: (value: string) => void
})
| (SWViewerHeaderItemBase & {
/** Caption above a switch, e.g. 2.5D. */
type: "switch"
checked: boolean
onCheckedChange?: (checked: boolean) => void
})
| {
/** Free content in the header (program-specific control). */
type: "custom"
id: string
hidden?: boolean
collapseBelow?: number
render: () => ReactNode
/** How the item appears in More once collapsed. Default: not shown there. */
menuEntries?: SWViewerMenuEntry[]
}
export interface SWViewerHeaderGroup {
id: string
/** Accessible group name. Also the More section title of its buttons, toggles and switches. */
label: string
/** More section title when it differs from `label`. */
moreLabel?: string
/** `plain` (default): no face. `field`: the inset dark property surface (user-confirmed values). */
surface?: "plain" | "field"
/** `end`: the trailing action area at the right end of the object surface. Default `start`. */
placement?: "start" | "end"
hidden?: boolean
items: SWViewerHeaderItem[]
}
+3
View File
@@ -10,3 +10,6 @@ export {
SWPopup, SWPopupSection, SWPopupRow,
type SWPopupProps, type SWPopupSectionProps, type SWPopupRowProps,
} from "./components/sw-popup/SWPopup"
// SW viewer mode (2026-10-19): viewer header, dropdown, popup panel and panel controls.
export * from "./components/sw-viewer"
+487
View File
@@ -0,0 +1,487 @@
/* @hm-design/sw — SW viewer mode (reference: DWG/DXF viewer top menu and floating panels, measured 2026-10-19).
Viewer-only tokens. The three header surfaces reuse the user-confirmed SW tokens of sw.css
(--hm-sw-header-document-* / object-* / property-*). Never touches global selectors or the SWHeader. */
.hm-sw-header,
.hm-sw-theme {
/* Header */
--hm-sw-viewer-height: 52px;
--hm-sw-viewer-background: linear-gradient(180deg, #266651 0%, #123328 45%, #0a221d 100%);
--hm-sw-viewer-border-color: #071e18;
--hm-sw-viewer-brand-width: 72px;
/* No logo: no text or icon is drawn; the cap keeps only its slanted edge (no wide empty space). */
--hm-sw-viewer-brand-empty-width: 24px;
--hm-sw-viewer-brand-top: #123328;
--hm-sw-viewer-brand-middle: #205241;
--hm-sw-viewer-brand-bottom: #071e18;
--hm-sw-viewer-text: #fff;
--hm-sw-viewer-muted: #bddfd4;
--hm-sw-viewer-accent: #ffc600;
--hm-sw-viewer-hover: #266651;
--hm-sw-viewer-gap: 4px;
--hm-sw-viewer-radius: 4px;
--hm-sw-viewer-control-height: 40px;
--hm-sw-viewer-button-height: 34px;
--hm-sw-viewer-font-size: 14px;
--hm-sw-viewer-label-size: 11px;
--hm-sw-viewer-icon-size: 16px;
--hm-sw-viewer-disabled-opacity: 0.6;
--hm-sw-viewer-switch-off: #0a221d;
--hm-sw-viewer-switch-bevel: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 1px rgba(0, 0, 0, 0.25);
/* Icon stroke: the SWHeader icon assets (print / K guide / layer icons) are a fill + a 1px black OUTSIDE stroke
(Figma mask "outside" path). SWViewerIcon draws it as SVG stroke on a copy behind the icon (see SWViewerIcon). */
--hm-sw-viewer-icon-outline-color: var(--hm-sw-header-text-outline-color);
--hm-sw-viewer-icon-outline-width: 1px;
/* Dropdown (light) and More (beige) */
--hm-sw-viewer-menu-background: #fff;
--hm-sw-viewer-menu-border-color: #dedbd2;
--hm-sw-viewer-menu-text: #0a221d;
--hm-sw-viewer-menu-muted: #6e6759;
--hm-sw-viewer-menu-highlight: rgba(189, 223, 212, 0.45);
--hm-sw-viewer-menu-highlight-text: #123328;
--hm-sw-viewer-menu-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
--hm-sw-viewer-menu-item-height: 36px;
--hm-sw-viewer-more-background: #f5f0e5;
--hm-sw-viewer-more-border-color: #897445;
/* Panel: the frame is SWPopup (title bar, title, close, border, radius, shadow). Content tokens map to the
SW property window tokens so viewer content sits on the same surface as every SW popup. */
--hm-sw-viewer-panel-background: var(--hm-sw-property-surface);
--hm-sw-viewer-panel-field-background: var(--hm-sw-property-content);
--hm-sw-viewer-panel-inner-border: var(--hm-sw-property-inner-border);
--hm-sw-viewer-panel-text: var(--hm-sw-property-label);
--hm-sw-viewer-panel-heading: #3e3523;
--hm-sw-viewer-panel-label: #6e6759;
--hm-sw-viewer-panel-primary: #123328;
--hm-sw-viewer-panel-switch-off: var(--hm-sw-property-inner-border);
--hm-sw-viewer-panel-switch-on: #266651;
--hm-sw-viewer-panel-badge-background: var(--hm-sw-property-table);
--hm-sw-viewer-panel-overlay: rgba(0, 0, 0, 0.1);
--hm-sw-viewer-panel-spacing: 12px;
}
.hm-sw-viewer-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ---------- Header ---------- */
.hm-sw-viewer-header {
container: hm-sw-viewer / inline-size;
position: relative;
box-sizing: border-box;
display: flex;
align-items: flex-start;
gap: 2px;
width: 100%;
min-width: 0;
height: var(--hm-sw-viewer-height);
border: 1px solid var(--hm-sw-viewer-border-color);
background: var(--hm-sw-viewer-background);
color: var(--hm-sw-viewer-text);
font-size: var(--hm-sw-viewer-font-size);
font-weight: 500;
line-height: 20px;
}
.hm-sw-viewer-header__brand {
position: relative;
z-index: 2;
display: flex;
align-items: center;
flex: 0 0 auto;
width: var(--hm-sw-viewer-brand-empty-width);
height: calc(var(--hm-sw-viewer-height) - 2px);
margin-right: -2px;
font-weight: 700;
}
/* With a logo: the reference 72px cap (logo at 24px), wider only for a wider logo. Without: the slanted edge only. */
.hm-sw-viewer-header__brand[data-logo] { width: auto; min-width: var(--hm-sw-viewer-brand-width); padding: 0 25px 0 24px; }
button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-header__brand-shape { position: absolute; inset: 0 auto 0 0; width: calc(100% + 16px); height: 100%; pointer-events: none; }
.hm-sw-viewer-header__brand-mark { position: relative; display: inline-flex; align-items: center; }
.hm-sw-viewer-header__brand-mark > :is(img, svg) { display: block; max-height: 24px; width: auto; }
.hm-sw-viewer-header__document {
display: flex;
align-items: center;
flex: 0 1 clamp(140px, 25cqw, 360px);
width: clamp(220px, 25cqw, 360px);
min-width: 56px;
height: calc(var(--hm-sw-viewer-height) - 2px);
padding: 0 16px 0 24px;
border: 1px solid var(--hm-sw-header-document-border-color);
border-left: 0;
background: var(--hm-sw-header-document-background);
}
.hm-sw-viewer-header__document-name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.hm-sw-viewer-header__object {
display: flex;
align-items: center;
flex: 1 0 auto;
min-width: 0;
height: calc(var(--hm-sw-viewer-height) - 2px);
gap: var(--hm-sw-viewer-gap);
padding-inline: var(--hm-sw-viewer-gap);
border: 1px solid var(--hm-sw-header-object-border-color);
border-radius: var(--hm-sw-header-object-radius);
background: var(--hm-sw-header-object-background);
}
.hm-sw-viewer-header__group {
display: flex;
align-items: center;
gap: var(--hm-sw-viewer-gap);
min-height: var(--hm-sw-viewer-control-height);
padding-inline: var(--hm-sw-viewer-gap);
}
.hm-sw-viewer-header__group[data-surface="field"] {
border: 1px solid var(--hm-sw-header-property-border-color);
border-radius: var(--hm-sw-header-property-radius);
background: var(--hm-sw-header-property-background);
box-shadow: var(--hm-sw-header-property-shadow);
}
.hm-sw-viewer-header__actions {
display: flex;
align-items: center;
align-self: stretch;
gap: var(--hm-sw-viewer-gap);
margin-left: auto;
padding-left: 6px;
}
/* Buttons, menu buttons and toggles: transparent, #266651 hover, #FFC600 pressed / open / on (no selected box). */
.hm-sw-viewer-header .hm-sw-viewer-header__button {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 8px;
height: var(--hm-sw-viewer-button-height);
padding: 6px 14px;
border: 1px solid transparent;
border-radius: var(--hm-sw-viewer-radius);
color: var(--hm-sw-viewer-text);
font-weight: 500;
line-height: 20px;
white-space: nowrap;
}
.hm-sw-viewer-header .hm-sw-viewer-header__button[data-kind="toggle"] { height: var(--hm-sw-viewer-control-height); padding: 0 8px; }
.hm-sw-viewer-header .hm-sw-viewer-header__button[data-icon-only] { min-width: var(--hm-sw-viewer-button-height); padding: 0 8px; }
/* Always-icon buttons (info, More): the reference 32 x 40 icon button. */
.hm-sw-viewer-header .hm-sw-viewer-header__button[data-icon-only="always"] { min-width: 32px; width: 32px; height: var(--hm-sw-viewer-control-height); padding: 0 8px; }
.hm-sw-viewer-header .hm-sw-viewer-header__button:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); }
.hm-sw-viewer-header .hm-sw-viewer-header__button:active:not(:disabled),
.hm-sw-viewer-header .hm-sw-viewer-header__button:is([data-state="open"], [data-state="on"]) { color: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-header .hm-sw-viewer-header__button:is([data-state="open"], [data-state="on"]):not(:hover) { background: transparent; }
.hm-sw-viewer-header :is(.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab):disabled { opacity: var(--hm-sw-viewer-disabled-opacity); cursor: not-allowed; }
.hm-sw-viewer-header__icon, .hm-sw-viewer-header__chevron { display: inline-flex; width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-header__icon > svg, .hm-sw-viewer-header__chevron > svg { width: 100%; height: 100%; }
/* SWViewerIcon: the outline copy (black SVG stroke) right behind the untouched icon. */
.hm-sw-viewer-icon { position: relative; display: inline-flex; width: 100%; height: 100%; }
.hm-sw-viewer-icon__outline, .hm-sw-viewer-icon__glyph { display: inline-flex; width: 100%; height: 100%; }
.hm-sw-viewer-icon__outline { position: absolute; inset: 0; overflow: visible; }
.hm-sw-viewer-icon__glyph { position: relative; }
.hm-sw-viewer-icon svg { width: 100%; height: 100%; overflow: visible; }
/* Tabs (Model / Layout): 40px triggers with an 11px count; the active one turns #FFC600. */
.hm-sw-viewer-header__tabs { display: flex; align-items: center; gap: var(--hm-sw-viewer-gap); }
.hm-sw-viewer-header .hm-sw-viewer-header__tab {
display: inline-flex;
align-items: center;
gap: var(--hm-sw-viewer-gap);
height: var(--hm-sw-viewer-control-height);
padding: 0 8px;
border: 1px solid transparent;
border-radius: var(--hm-sw-viewer-radius);
color: var(--hm-sw-viewer-text);
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
.hm-sw-viewer-header .hm-sw-viewer-header__tab:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); }
.hm-sw-viewer-header .hm-sw-viewer-header__tab[data-state="active"] { color: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-header__count { font-size: var(--hm-sw-viewer-label-size); font-weight: 400; font-variant-numeric: tabular-nums; }
/* Switch fields (2.5D, 어두운 배경): 11px muted caption above the switch. */
.hm-sw-viewer-header__switch-field {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--hm-sw-viewer-gap);
min-height: var(--hm-sw-viewer-control-height);
padding-inline: 8px;
}
.hm-sw-viewer-header__switch-label { color: var(--hm-sw-viewer-muted); font-size: var(--hm-sw-viewer-label-size); font-weight: 400; line-height: 1; white-space: nowrap; }
.hm-sw-viewer-header :is(.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__brand, .hm-sw-viewer-switch):focus-visible {
outline: 2px solid var(--hm-sw-viewer-accent);
outline-offset: 2px;
}
/* Text and icon stroke (2026-10-19 request): the SWHeader black outline on the viewer header, same tokens and
method — an outside outline of the glyph / icon shape, never a box border.
Text: 14px = --hm-sw-header-text-outline (1.3px black), 11px captions / counts / status = --hm-sw-header-label-outline
(1px black), as SWHeader's 12px labels. Icons: SWViewerIcon (SVG stroke, see the token). Fill color, size,
weight and spacing are unchanged; hover / active / open change only the fill, disabled fades the whole button
(opacity) like SWHeader. Dropdown faces (light / beige) and panels get no outline, like SWHeader's More menu. */
.hm-sw-viewer-header :is(
.hm-sw-viewer-header__brand-mark, .hm-sw-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab
) {
text-shadow: var(--hm-sw-header-text-outline);
}
.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);
}
/* Clipped (ellipsis) file name: the outline may paint 2px outside the box instead of being cut. */
.hm-sw-viewer-header .hm-sw-viewer-header__document-name {
overflow: clip;
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
}
/* Status */
.hm-sw-viewer-status {
display: flex;
align-items: center;
gap: var(--hm-sw-viewer-gap);
padding-inline: 8px;
color: var(--hm-sw-viewer-muted);
font-size: var(--hm-sw-viewer-label-size);
font-weight: 400;
white-space: nowrap;
}
.hm-sw-viewer-status__icon { display: inline-flex; width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-status[data-state="error"] { color: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-status__spin { animation: hm-sw-viewer-spin 1s linear infinite; }
@keyframes hm-sw-viewer-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .hm-sw-viewer-status__spin { animation: none; } }
/* Narrow header (reference breakpoints, measured on the header itself). */
@container hm-sw-viewer (max-width: 1179.98px) {
.hm-sw-viewer-header__object { gap: 2px; padding-inline: 2px; }
.hm-sw-viewer-header__group { gap: 2px; padding-inline: 2px; }
.hm-sw-viewer-header__actions { padding-left: 2px; gap: 2px; }
.hm-sw-viewer-status { padding-inline: 2px; }
}
@container hm-sw-viewer (max-width: 559.98px) {
.hm-sw-viewer-header__document { padding-right: 8px; }
.hm-sw-viewer-status__label { display: none; }
}
/* ---------- Switch (header / panel faces) ---------- */
.hm-sw-theme .hm-sw-viewer-switch {
position: relative;
display: inline-flex;
align-items: center;
flex-shrink: 0;
width: 36px;
height: 20px;
padding: 2px;
border-radius: 9999px;
background: var(--hm-sw-viewer-switch-off);
box-shadow: var(--hm-sw-viewer-switch-bevel);
transition: background-color 0.15s;
}
.hm-sw-viewer-switch__thumb { display: block; width: 16px; height: 16px; border-radius: 9999px; background: var(--hm-sw-viewer-text); transition: transform 0.15s; }
.hm-sw-viewer-switch[data-state="checked"] { background: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-switch[data-state="checked"] .hm-sw-viewer-switch__thumb { transform: translateX(16px); background: var(--hm-sw-viewer-switch-off); }
.hm-sw-theme .hm-sw-viewer-switch:disabled { opacity: var(--hm-sw-viewer-disabled-opacity); cursor: not-allowed; }
.hm-sw-viewer-panel .hm-sw-viewer-switch { background: var(--hm-sw-viewer-panel-switch-off); box-shadow: none; }
.hm-sw-viewer-panel .hm-sw-viewer-switch .hm-sw-viewer-switch__thumb { background: #fff; }
.hm-sw-viewer-panel .hm-sw-viewer-switch[data-state="checked"] { background: var(--hm-sw-viewer-panel-switch-on); }
@media (prefers-reduced-motion: reduce) { .hm-sw-viewer-switch, .hm-sw-viewer-switch__thumb { transition: none; } }
/* ---------- Dropdown ---------- */
.hm-sw-viewer-menu {
position: fixed;
z-index: 60;
display: flex;
flex-direction: column;
min-width: 160px;
max-width: calc(100vw - 8px);
max-height: min(640px, calc(100dvh - 8px));
overflow-y: auto;
padding: 4px;
border: 1px solid var(--hm-sw-viewer-menu-border-color);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-menu-background);
box-shadow: var(--hm-sw-viewer-menu-shadow);
color: var(--hm-sw-viewer-menu-text);
font-size: 14px;
font-weight: 400;
line-height: 20px;
outline: none;
}
.hm-sw-viewer-menu[data-surface="beige"] { background: var(--hm-sw-viewer-more-background); border-color: var(--hm-sw-viewer-more-border-color); }
.hm-sw-viewer-menu .hm-sw-viewer-menu__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: var(--hm-sw-viewer-menu-item-height);
padding: 0 12px;
border-radius: 2px;
color: inherit;
text-align: left;
white-space: nowrap;
outline: none;
}
.hm-sw-viewer-menu .hm-sw-viewer-menu__item:is(:focus, :hover, [data-state="open"]):not([aria-disabled]) { background: var(--hm-sw-viewer-menu-highlight); color: var(--hm-sw-viewer-menu-highlight-text); }
.hm-sw-viewer-menu .hm-sw-viewer-menu__item[aria-disabled] { opacity: 0.5; cursor: not-allowed; }
.hm-sw-viewer-menu .hm-sw-viewer-menu__item:focus-visible { outline: none; }
.hm-sw-viewer-menu__icon { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 16px; width: 16px; height: 16px; }
.hm-sw-viewer-menu__icon:empty { display: none; }
.hm-sw-viewer-menu__item[role="menuitemcheckbox"] .hm-sw-viewer-menu__icon,
.hm-sw-viewer-menu__item[role="menuitemradio"] .hm-sw-viewer-menu__icon { display: inline-flex; }
.hm-sw-viewer-menu__icon > svg { width: 16px; height: 16px; }
.hm-sw-viewer-menu__dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; }
.hm-sw-viewer-menu__text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.hm-sw-viewer-menu__shortcut { margin-left: auto; color: var(--hm-sw-viewer-menu-muted); font-size: 12px; letter-spacing: 0.05em; }
.hm-sw-viewer-menu__chevron { display: inline-flex; margin-left: auto; color: var(--hm-sw-viewer-menu-muted); }
.hm-sw-viewer-menu__label { padding: 6px 12px; color: var(--hm-sw-viewer-menu-muted); font-size: 12px; font-weight: 500; line-height: 18px; }
.hm-sw-viewer-menu__separator { flex: 0 0 1px; height: 1px; margin: 4px -4px; background: var(--hm-sw-viewer-menu-border-color); }
.hm-sw-viewer-menu[data-surface="beige"] .hm-sw-viewer-menu__separator { background: var(--hm-sw-viewer-panel-inner-border); }
/* ---------- Panel (SWPopup frame + viewer window behavior) ---------- */
.hm-sw-viewer-panel-backdrop { inset: 0; background: var(--hm-sw-viewer-panel-overlay); }
.hm-sw-viewer-panel[data-collapsed] .hm-sw-property__body { display: none; }
.hm-sw-viewer-panel[data-draggable] .hm-sw-property__titlebar { cursor: grab; touch-action: none; user-select: none; }
.hm-sw-viewer-panel[data-dragging] .hm-sw-property__titlebar { cursor: grabbing; }
/* Any width: the Figma title art keeps its 308px curve at the close button (right-aligned; at 314px this is exactly
the SWPopup position), and a stretched copy of the same art fills the flat part on the left of wider panels.
The right one hides its own left 20px (left edge + corner) and the left copy ends exactly there: no seam, no
doubled shadow. */
.hm-sw-viewer-panel .hm-sw-property__title-art { left: auto; right: 4px; clip-path: inset(-4px -4px -4px 20px); }
.hm-sw-viewer-panel .hm-sw-property__titlebar::before {
content: "";
position: absolute;
z-index: 1;
left: 0;
top: -2px;
width: max(0px, calc(100% - 292px));
height: 38px;
background: url("../assets/property-title-single.svg") left top / calc(100% + 288px) 38px no-repeat;
transform: scaleY(-1);
pointer-events: none;
}
.hm-sw-viewer-panel__actions { position: relative; z-index: 2; display: flex; flex-shrink: 0; align-items: center; height: 30px; }
/* Title-bar buttons: SW text-only look (no face), #FFC600 on hover like the SW title-bar controls. */
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--hm-sw-viewer-muted);
}
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):hover:not(:disabled) { background: transparent; color: var(--hm-sw-header-selected-color); }
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):focus-visible { outline: 2px solid var(--hm-sw-header-selected-color); outline-offset: -2px; }
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) svg { width: 16px; height: 16px; }
.hm-sw-viewer-panel__footer {
display: flex;
flex-shrink: 0;
justify-content: flex-end;
gap: 8px;
padding: 7px;
border-top: 1px solid var(--hm-sw-viewer-panel-inner-border);
}
/* Panel content */
.hm-sw-viewer-section { display: flex; flex-direction: column; gap: 12px; }
.hm-sw-viewer-section[data-surface="card"] {
padding: var(--hm-sw-viewer-panel-spacing);
border: 1px solid var(--hm-sw-viewer-panel-inner-border);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-panel-field-background);
box-shadow: inset 0 1px 0 #fff;
}
.hm-sw-viewer-section__title { margin: 0; color: var(--hm-sw-viewer-panel-heading); font-size: 12px; font-weight: 700; line-height: 16px; letter-spacing: 0.025em; text-transform: uppercase; }
.hm-sw-viewer-hint { margin: 0; color: var(--hm-sw-viewer-panel-heading); font-size: 12px; line-height: 16px; }
.hm-sw-viewer-field { display: flex; flex-direction: column; gap: 6px; }
.hm-sw-viewer-field[data-disabled] { opacity: 0.5; pointer-events: none; }
.hm-sw-viewer-field__row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 20px; }
.hm-sw-viewer-field__label { min-width: 0; color: var(--hm-sw-viewer-panel-label); font-size: 14px; font-weight: 500; line-height: 20px; }
.hm-sw-viewer-field__control { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.hm-sw-viewer-field__unit { color: var(--hm-sw-viewer-panel-label); font-size: 12px; }
.hm-sw-theme .hm-sw-viewer-input {
box-sizing: border-box;
width: 80px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--hm-sw-viewer-panel-inner-border);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-panel-field-background);
color: var(--hm-sw-viewer-panel-text);
font: inherit;
font-size: 12px;
line-height: 18px;
}
.hm-sw-theme .hm-sw-viewer-input:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: -1px; }
.hm-sw-theme .hm-sw-viewer-input:disabled { opacity: 0.5; }
.hm-sw-theme .hm-sw-viewer-slider {
--hm-sw-viewer-slider-percent: 0%;
width: 100%;
height: 18px;
margin: 0;
background: transparent;
appearance: none;
cursor: pointer;
}
.hm-sw-viewer-slider::-webkit-slider-runnable-track { height: 6px; border-radius: 9999px; background: linear-gradient(90deg, var(--hm-sw-viewer-panel-switch-on) var(--hm-sw-viewer-slider-percent), var(--hm-sw-viewer-panel-background) var(--hm-sw-viewer-slider-percent)); }
.hm-sw-viewer-slider::-moz-range-track { height: 6px; border-radius: 9999px; background: var(--hm-sw-viewer-panel-background); }
.hm-sw-viewer-slider::-moz-range-progress { height: 6px; border-radius: 9999px; background: var(--hm-sw-viewer-panel-switch-on); }
.hm-sw-viewer-slider::-webkit-slider-thumb { width: 18px; height: 18px; margin-top: -6px; border: 2px solid var(--hm-sw-viewer-panel-switch-on); border-radius: 9999px; background: #fff; appearance: none; }
.hm-sw-viewer-slider::-moz-range-thumb { box-sizing: border-box; width: 18px; height: 18px; border: 2px solid var(--hm-sw-viewer-panel-switch-on); border-radius: 9999px; background: #fff; }
.hm-sw-theme .hm-sw-viewer-slider:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: 2px; }
.hm-sw-theme .hm-sw-viewer-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border: 1px solid var(--hm-sw-viewer-panel-primary);
border-radius: var(--hm-sw-viewer-radius);
background: #fff;
color: var(--hm-sw-viewer-panel-primary);
font-size: 12px;
font-weight: 500;
line-height: 16px;
white-space: nowrap;
}
.hm-sw-theme .hm-sw-viewer-button[data-variant="outline"]:hover:not(:disabled) { background: var(--hm-sw-viewer-panel-field-background); }
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"] { background: var(--hm-sw-viewer-panel-primary); color: #fff; }
/* tone="green" (e.g. 전체 켜기 / 전체 끄기): default unchanged. Hover = the primary (확인) button hover, the same token;
pressed = the primary button face. White text as on the primary button. Back to default / hover after the press. */
.hm-sw-theme .hm-sw-viewer-button[data-tone="green"]:hover:not(:disabled) { border-color: var(--hm-sw-viewer-hover); background: var(--hm-sw-viewer-hover); color: #fff; }
.hm-sw-theme .hm-sw-viewer-button[data-tone="green"]:active:not(:disabled) { border-color: var(--hm-sw-viewer-panel-primary); background: var(--hm-sw-viewer-panel-primary); color: #fff; }
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"]:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); border-color: var(--hm-sw-viewer-hover); }
.hm-sw-theme .hm-sw-viewer-button:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: 1px; }
.hm-sw-theme .hm-sw-viewer-button:disabled { opacity: 0.5; cursor: not-allowed; }
.hm-sw-viewer-button__icon { display: inline-flex; width: 16px; height: 16px; }
.hm-sw-viewer-button__icon > svg { width: 100%; height: 100%; }
.hm-sw-viewer-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: var(--hm-sw-viewer-radius); background: var(--hm-sw-viewer-panel-badge-background); color: var(--hm-sw-viewer-panel-label); font-size: 12px; font-weight: 500; line-height: 18px; font-variant-numeric: tabular-nums; }
/* Corner resize grip (replaces native resize: same corner and glyph idea, works in every browser and with
pointer capture; the size stays within min / 90% of the bounds / the bounds edge). */
.hm-sw-viewer-panel__resize {
position: absolute;
right: 0;
bottom: 0;
z-index: 1;
width: 14px;
height: 14px;
cursor: nwse-resize;
touch-action: none;
background:
linear-gradient(135deg, transparent 0 55%, var(--hm-sw-viewer-panel-inner-border) 55% 62%, transparent 62% 72%, var(--hm-sw-viewer-panel-inner-border) 72% 79%, transparent 79%);
}
@@ -14,6 +14,9 @@ export const inventorySignature = entries => entries.map(entry => [entry.id, ent
// SW/Header next to its 10 retained Stories, the rest under SW/Tool and SW/Popup), and the only package files it changed.
// The two Header Stories sit directly under SW/Header (no separate Options menu), in src/sw/sw-header-open.stories.tsx.
export const integratedStoryIds = ['sw-header--document-open', 'sw-header--hidden-sub-controls', 'sw-tool--tool-buttons', 'sw-popup--custom-content', 'sw-popup--multiple-popups']
// 2026-10-19 (viewer worktree, user request): the SW/Tool Story menu was removed; SW/Popup (the common SWPopup
// examples) stays. SWToolButton / SWToolGroup stay public; integrated-improvement.browser.mjs opening this id is historical evidence only.
export const retiredToolIds = ["sw-tool--tool-buttons"]
export const integratedChangedFiles = [
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
@@ -0,0 +1,62 @@
// Regression for the SW viewer work (2026-10-19): the existing SW/Header and SW/Popup Stories must render their
// headers and popups pixel-identical before (base Storybook) and after (this branch) at several widths.
// Element mode compares every .hm-sw-header / .hm-sw-property element; the full page is reported for information
// (the 2026-10-19 follow-up changed the Story work-area background to the shared grid canvas on purpose).
// Usage: node packages/sw/tests/viewer-regression.browser.mjs <base-storybook-url> <new-storybook-url> <out-dir>
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const { chromium } = require('playwright')
const { PNG } = require('pngjs')
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
const [base, next, outArg, mode] = process.argv.slice(2)
// --neutral-canvas: on the new side only, put back the pre-grid Story backgrounds (white work area, page color under
// dropdowns), so semi-transparent header / dropdown edge pixels compare against the same backdrop.
const neutral = mode === "--neutral-canvas"
const out = path.resolve(outArg ?? 'docs/sw/viewer-qa/regression')
await mkdir(out, { recursive: true })
const ids = ['overview', 'interactive-states', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls', 'document-open', 'hidden-sub-controls', 'detail-buttons'].map(id => `sw-header--${id}`)
.concat(['sw-popup--custom-content', 'sw-popup--multiple-popups'])
const widths = [1920, 1180, 768]
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const shot = async (url, width, restore = false) => {
const page = await browser.newPage({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' })
await page.goto(url)
await page.waitForSelector('#storybook-root > *')
if (restore) await page.addStyleTag({ content: '.sw-overview-stage { background: var(--hm-sem-bg-surface) !important; } .sw-story-scroller--menu { background: none !important; }' })
await page.evaluate(() => document.fonts.ready)
await page.waitForTimeout(400)
const full = PNG.sync.read(await page.screenshot({ fullPage: true, animations: 'disabled' }))
const elements = []
for (const element of await page.locator('.hm-sw-header, .hm-sw-property').all()) {
if (!(await element.isVisible())) continue
elements.push(PNG.sync.read(await element.screenshot({ animations: 'disabled' })))
}
await page.close()
return { full, elements }
}
const compare = (a, b, out) => {
if (a.width !== b.width || a.height !== b.height) return -1
const image = new PNG({ width: a.width, height: a.height })
const diff = pixelmatch(a.data, b.data, image.data, a.width, a.height, { threshold: 0 })
if (diff && out) return { diff, image }
return diff
}
const results = []
for (const id of ids) for (const width of widths) {
const url = root => `${root}/iframe.html?id=${id}&viewMode=story`
const [a, b] = [await shot(url(base), width), await shot(url(next), width, neutral)]
const elementDiffs = a.elements.length === b.elements.length ? a.elements.map((item, index) => compare(item, b.elements[index])) : [-1]
const fullDiff = compare(a.full, b.full)
const pass = elementDiffs.every(diff => diff === 0)
results.push({ id, width, elements: a.elements.length + ' / ' + b.elements.length, elementDiffPixels: elementDiffs, fullPageDiffPixels: fullDiff, result: pass ? 'PASS' : 'FAIL' })
console.log(`${pass ? 'PASS' : 'FAIL'} ${id} @${width} elements ${a.elements.length}/${b.elements.length} diff [${elementDiffs.join(',')}] full-page diff ${fullDiff}`)
}
await browser.close()
await writeFile(path.join(out, neutral ? 'regression-neutral-canvas.json' : 'regression.json'), JSON.stringify({ base, next, results }, null, 2) + '\n')
const failed = results.filter(item => item.result !== 'PASS').length
console.log(`${results.length - failed}/${results.length} PASS`)
process.exit(failed ? 1 : 0)
+415
View File
@@ -0,0 +1,415 @@
// SW viewer mode verification (2026-10-19).
// 1) Captures the reference DWG/DXF viewer and the SW/Viewer Stories at the same resolution (1440 x 900).
// 2) Runs interaction checks on the Stories (menus, keyboard, Escape, outside click, panels, More, modal).
// Usage: node packages/sw/tests/viewer.browser.mjs <storybook-url> <reference-url|-> <out-dir>
// e.g. node packages/sw/tests/viewer.browser.mjs http://127.0.0.1:6034 http://127.0.0.1:5182 docs/sw/viewer-qa
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const { chromium } = require('playwright')
const [storybook, reference, outArg] = process.argv.slice(2)
const out = path.resolve(outArg ?? 'docs/sw/viewer-qa')
await mkdir(out, { recursive: true })
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const results = []
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) }
const errors = []
const story = id => `${storybook}/iframe.html?id=sw-viewer--${id}&viewMode=story`
const newPage = async (width = 1440, height = 900) => {
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' })
page.on('pageerror', error => errors.push(`${page.url()}: ${error.message}`))
page.on('console', message => { if (message.type() === 'error' && !/favicon|404/.test(message.text())) errors.push(`${page.url()}: ${message.text()}`) })
return page
}
// Test-only: lift the 1280px Story page envelope so the 1440px frames are measured at full width.
const open = async (page, id) => { await page.goto(story(id)); await page.waitForSelector('.hm-sw-viewer-header'); await page.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' }); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(200) }
const log = page => page.locator('[data-callback-log]').first().innerText()
const box = locator => locator.boundingBox()
// ---------- 1. Same-resolution captures ----------
const geometry = async (page, selectors) => page.evaluate(selectors => {
const header = document.querySelector(selectors.header)
const base = header.getBoundingClientRect()
return Object.fromEntries(Object.entries(selectors.parts).map(([name, selector]) => [name, [...document.querySelectorAll(selector)].map(element => {
const rect = element.getBoundingClientRect()
return { x: Math.round(rect.x - base.x), y: Math.round(rect.y - base.y), w: Math.round(rect.width), h: Math.round(rect.height), text: element.textContent.trim().slice(0, 12) }
})]))
}, selectors)
const parts = {
reference: { header: 'header', parts: { document: '.cad-toolbar-document', object: '.cad-toolbar-object-area', field: '.cad-toolbar-property-field', button: '.cad-toolbar [data-slot=button]', toggle: '.cad-toolbar [data-slot=toggle]', tab: '.cad-toolbar [data-slot=tabs-trigger]', switchField: '.cad-toolbar-switch-field' } },
package: { header: '.hm-sw-viewer-header', parts: { document: '.hm-sw-viewer-header__document', object: '.hm-sw-viewer-header__object', field: '.hm-sw-viewer-header__group[data-surface=field]', button: '.hm-sw-viewer-header__button:not([data-kind=toggle])', toggle: '.hm-sw-viewer-header__button[data-kind=toggle]', tab: '.hm-sw-viewer-header__tab', switchField: '.hm-sw-viewer-header__switch-field' } },
}
const comparison = {}
if (reference && reference !== '-') {
const page = await newPage()
await page.goto(reference)
await page.waitForSelector('header')
await page.evaluate(() => document.fonts.ready)
await page.screenshot({ path: path.join(out, 'reference-1440-idle.png'), clip: { x: 0, y: 0, width: 1440, height: 56 } })
await page.getByRole('button', { name: '샘플' }).click()
await page.screenshot({ path: path.join(out, 'reference-1440-sample-menu.png'), clip: { x: 0, y: 0, width: 1440, height: 200 } })
await page.getByRole('menuitem', { name: /평면 및 종단면도/ }).click()
await page.waitForFunction(() => document.querySelector('.cad-toolbar-filename')?.textContent?.includes('C0060202'), null, { timeout: 30000 })
await page.waitForTimeout(1500)
comparison.reference = await geometry(page, parts.reference)
await page.screenshot({ path: path.join(out, 'reference-1440-loaded.png'), clip: { x: 0, y: 0, width: 1440, height: 56 } })
await page.getByRole('button', { name: '레이어 패널' }).click()
await page.getByRole('button', { name: '옵션 패널' }).click()
await page.waitForTimeout(300)
await page.screenshot({ path: path.join(out, 'reference-1440-panels.png') })
await page.close()
}
{
const page = await newPage(1920, 1000)
await open(page, 'viewer-header')
const frame = page.locator('[data-viewer-frame]')
const origin = await box(frame)
comparison.package = await geometry(page, parts.package)
await page.screenshot({ path: path.join(out, 'package-1440-loaded.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 56 } })
await page.getByRole('button', { name: '샘플' }).click()
await page.screenshot({ path: path.join(out, 'package-1440-sample-menu.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 200 } })
await page.keyboard.press('Escape')
await page.getByRole('button', { name: '레이어' }).click()
await page.getByRole('button', { name: '설정' }).click()
await page.waitForTimeout(200)
await page.screenshot({ path: path.join(out, 'package-1440-panels.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 620 } })
await page.close()
}
if (comparison.reference) {
// Same item = same size (positions shift with the file-name text width, which differs by sample data).
for (const key of ['button', 'toggle', 'tab', 'switchField', 'field']) {
const a = comparison.reference[key].map(item => `${item.w}x${item.h}`), b = comparison.package[key].map(item => `${item.w}x${item.h}`)
// field: the reference Tabs root is 39px inside its 40px frame, so its tab field is 41px; the package keeps both fields 42px (documented 1px difference).
const within = key === 'field' ? comparison.reference[key].every((item, index) => comparison.package[key][index] && item.w === comparison.package[key][index].w && Math.abs(item.h - comparison.package[key][index].h) <= 1) : JSON.stringify(a) === JSON.stringify(b)
check(`geometry ${key} sizes equal to reference${key === 'field' ? ' (±1px height, known)' : ''}`, within, `reference ${a.join(' ')} / package ${b.join(' ')}`)
}
const [r, p] = [comparison.reference.document[0], comparison.package.document[0]]
// x differs by design: the reference always draws "HM" (72px cap); the package draws no logo unless given (24px cap).
check('geometry document well height / top (x: logo rule)', r.h === p.h && r.y === p.y && p.x === r.x - 48, `reference x${r.x} y${r.y} h${r.h} / package x${p.x} y${p.y} h${p.h}`)
}
// ---------- 2. Interactions ----------
{
// Wide enough for the 1440px story frame plus the story page padding (panels at the frame right edge stay on screen).
const page = await newPage(1920, 1000)
await open(page, 'viewer-header')
// Dropdown: click opens, entries listed, Escape closes and returns focus.
const samples = page.getByRole('button', { name: '샘플' })
await samples.click()
const menu = page.getByRole('menu', { name: '샘플' })
check('menu opens on click', await menu.isVisible())
check('menu has 3 entries', (await menu.getByRole('menuitem').count()) === 3)
const menuBox = await box(menu), triggerBox = await box(samples)
check('menu placed under its button', Math.abs(menuBox.x - triggerBox.x) <= 1 && menuBox.y >= triggerBox.y + triggerBox.height, `menu ${Math.round(menuBox.x)},${Math.round(menuBox.y)} button ${Math.round(triggerBox.x)},${Math.round(triggerBox.y + triggerBox.height)}`)
check('trigger shows open state', (await samples.getAttribute('aria-expanded')) === 'true' && (await samples.getAttribute('data-state')) === 'open')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
check('ArrowDown moves focus in menu', (await page.evaluate(() => document.activeElement?.textContent)).includes('평면'))
await page.keyboard.press('Escape')
check('Escape closes menu', !(await menu.isVisible().catch(() => false)))
check('Escape returns focus to trigger', await samples.evaluate(element => element === document.activeElement))
// Keyboard open + Enter select → file name changes, command logged.
await samples.press('Enter')
check('Enter opens with first entry focused', (await page.evaluate(() => document.activeElement?.getAttribute('role'))) === 'menuitem')
await page.keyboard.press('End')
await page.keyboard.press('Enter')
check('Enter selects entry (document changes)', (await page.locator('.hm-sw-viewer-header__document-name').innerText()) === 'simple.dxf')
check('onCommand reports menu entry', (await log(page)).includes('samples/sample-dxf'), await log(page))
// Outside click closes.
await samples.click()
await page.mouse.click(700, 600)
check('outside click closes menu', (await page.getByRole('menu').count()) === 0)
// Tabs: arrow keys select.
const model = page.getByRole('radio', { name: /Model/ })
await model.focus()
await page.keyboard.press('ArrowRight')
check('tabs ArrowRight selects Layout', (await page.getByRole('radio', { name: /Layout/ }).getAttribute('aria-checked')) === 'true')
// Switch
const depth = page.getByRole('switch', { name: '2.5D' })
await depth.click()
check('header switch toggles', (await depth.getAttribute('aria-checked')) === 'true' && (await log(page)).includes('2.5d=true'))
// Disabled fit button is enabled once a document is open; hover face.
const fit = page.getByRole('button', { name: '전체 보기' })
await fit.hover()
check('hover face #266651', (await fit.evaluate(element => getComputedStyle(element).backgroundColor)) === 'rgb(38, 102, 81)')
// Toggle opens the panel; Escape inside closes it and focus goes back.
const layers = page.getByRole('button', { name: '레이어', exact: true })
await layers.click()
const layerPanel = page.getByRole('dialog', { name: /레이어 \(/ })
check('toggle opens panel', await layerPanel.isVisible())
check('toggle pressed state + #FFC600', (await layers.getAttribute('aria-pressed')) === 'true' && (await layers.evaluate(element => getComputedStyle(element).color)) === 'rgb(255, 198, 0)')
check('panel focused on open', await layerPanel.evaluate(element => element.contains(document.activeElement)))
await page.keyboard.press('Escape')
check('Escape closes panel', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
check('focus returns to toggle', await layers.evaluate(element => element === document.activeElement))
// Resize by the native corner (right after opening, top-end placement).
await layers.click()
const sized = await box(layerPanel)
await page.mouse.move(sized.x + sized.width - 6, sized.y + sized.height - 6)
await page.mouse.down()
await page.mouse.move(sized.x + sized.width - 63, sized.y + sized.height - 103, { steps: 6 })
await page.mouse.up()
const resized = await box(layerPanel)
check("corner resize changes size", resized.width < sized.width - 40 && resized.height < sized.height - 80, `${Math.round(sized.width)}x${Math.round(sized.height)} → ${Math.round(resized.width)}x${Math.round(resized.height)}`)
// Drag: title bar moves the panel and it stays inside the frame.
const before = await box(layerPanel)
const title = layerPanel.locator('.hm-sw-property__titlebar')
const titleBox = await box(title)
await page.mouse.move(titleBox.x + 40, titleBox.y + 15)
await page.mouse.down()
await page.mouse.move(titleBox.x - 260, titleBox.y + 45, { steps: 8 })
await page.mouse.up()
const after = await box(layerPanel)
check('drag moves panel', Math.round(before.x - after.x) === 300 && Math.round(after.y - before.y) === 30, `dx ${Math.round(after.x - before.x)} dy ${Math.round(after.y - before.y)}`)
await page.mouse.move(after.x + 40, after.y + 15)
await page.mouse.down()
await page.mouse.move(after.x + 3000, after.y + 3000, { steps: 6 })
await page.mouse.up()
const frame = await box(page.locator('.sw-viewer-story-canvas'))
const clamped = await box(layerPanel)
check('drag stays inside bounds (10px edge)', Math.round(frame.x + frame.width - (clamped.x + clamped.width)) === 10 && Math.round(frame.y + frame.height - (clamped.y + clamped.height)) === 10,
`right gap ${Math.round(frame.x + frame.width - (clamped.x + clamped.width))}, bottom gap ${Math.round(frame.y + frame.height - (clamped.y + clamped.height))}`)
// Collapse by button and double click.
await layerPanel.getByRole('button', { name: '접기' }).click()
check('collapse hides body', !(await layerPanel.locator('.hm-sw-property__body').isVisible()))
await title.dblclick({ position: { x: 30, y: 15 } })
check('title double click expands', await layerPanel.locator('.hm-sw-property__body').isVisible())
// Panel content works (layer eye).
// 전체 켜기 / 전체 끄기: green on hover and while pressed, default otherwise; other panel buttons unchanged.
const allOn = layerPanel.getByRole('button', { name: '전체 켜기' })
const face = locator => locator.evaluate(element => [getComputedStyle(element).backgroundColor, getComputedStyle(element).color].join(' '))
const restFace = await face(allOn)
await allOn.hover()
const hoverFace = await face(allOn)
await page.mouse.down()
const activeFace = await face(allOn)
await page.mouse.up()
const afterFace = await face(allOn)
await page.mouse.move(5, 5)
const leftFace = await face(allOn)
check('전체 켜기: default kept, green hover / active, back after release', restFace === 'rgb(255, 255, 255) rgb(18, 51, 40)' && hoverFace === 'rgb(38, 102, 81) rgb(255, 255, 255)' && activeFace === 'rgb(18, 51, 40) rgb(255, 255, 255)' && afterFace === hoverFace && leftFace === restFace, [restFace, hoverFace, activeFace, afterFace, leftFace].join(' | '))
await layerPanel.getByRole('button', { name: 'CR-TEXT 숨기기' }).click()
check('panel content callback', (await log(page)).includes('layer:CR-TEXT=false'))
// Close button hover face.
const close = layerPanel.getByRole('button', { name: '닫기' })
await close.hover()
check('close hover = SWPopup hover asset', await close.evaluate(element => getComputedStyle(element.querySelector('.hm-sw-property__close-hover')).visibility === 'visible' && getComputedStyle(element.querySelector('.hm-sw-property__close-default')).visibility === 'hidden'))
await close.click()
check('close button closes', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
// Two panels: the last touched comes to the front.
await layers.click()
await page.getByRole('button', { name: '설정', exact: true }).click()
const options = page.getByRole('dialog', { name: '옵션' })
const z = async () => Promise.all([layerPanel, options].map(item => item.evaluate(element => Number(getComputedStyle(element).zIndex))))
await layerPanel.locator('.hm-sw-property__titlebar').click({ position: { x: 30, y: 15 } })
const [zl, zo] = await z()
check('last touched panel on top', zl > zo, `layer z ${zl}, options z ${zo}`)
// Modal popup: backdrop, focus trap, Escape.
await page.getByRole('button', { name: '정보 및 라이선스' }).click()
const about = page.getByRole('dialog', { name: /라이선스/ })
const confirm = about.getByRole('button', { name: '확인' })
await confirm.hover()
const confirmHover = await face(confirm)
check('전체 켜기 hover = 라이선스 확인 hover (same token)', confirmHover === hoverFace, `${confirmHover} / ${hoverFace}`)
await about.locator('.hm-sw-property__title').hover()
check('modal popup opens with backdrop', (await about.getAttribute('aria-modal')) === 'true' && (await page.locator('.hm-sw-viewer-panel-backdrop').count()) === 1)
for (let index = 0; index < 4; index++) await page.keyboard.press('Tab')
check('modal focus stays inside', await about.evaluate(element => element.contains(document.activeElement)))
await page.keyboard.press('Escape')
check('modal Escape closes', (await page.getByRole('dialog', { name: /라이선스/ }).count()) === 0)
await page.close()
}
{
// Submenus and the context menu.
const page = await newPage(1920, 1100)
await open(page, 'dropdowns')
await page.getByRole('button', { name: '도면' }).press('Enter')
const recent = page.getByRole('menuitem', { name: '최근 도면' })
await recent.focus()
await page.keyboard.press('ArrowRight')
const sub = page.getByRole('menu', { name: '최근 도면' })
check('ArrowRight opens submenu with focus', (await sub.isVisible()) && await sub.evaluate(element => element.contains(document.activeElement)))
const parentBox = await box(page.getByRole('menu', { name: '도면', exact: true })), subBox = await box(sub)
check('submenu opens to the side', subBox.x >= parentBox.x + parentBox.width, `parent right ${Math.round(parentBox.x + parentBox.width)}, sub left ${Math.round(subBox.x)}`)
await page.getByRole('menuitem', { name: '보관 도면' }).hover()
check('third level opens on hover', await page.getByRole('menu', { name: '보관 도면' }).isVisible())
await page.getByRole('menuitem', { name: '보관 도면' }).focus()
await page.keyboard.press('ArrowLeft')
check('ArrowLeft closes one level', (await page.getByRole('menu', { name: '최근 도면' }).count()) === 0 && (await page.getByRole('menu', { name: '도면', exact: true }).count()) === 1)
await page.getByRole('menuitemcheckbox', { name: '선가중치 표시' }).click()
check('checkbox entry toggles via onCommand', (await log(page)).includes('file/lineweight=true'), await log(page))
await page.getByRole('button', { name: '도면' }).click()
check('checkbox state reflected', (await page.getByRole('menuitemcheckbox', { name: '선가중치 표시' }).getAttribute('aria-checked')) === 'true')
await page.getByRole('menuitem', { name: '인쇄 (미연결)' }).click({ force: true })
check('disabled entry does nothing', (await page.getByRole('menu', { name: '도면', exact: true }).count()) === 1)
await page.keyboard.press('Escape')
const right = page.getByRole('button', { name: '오른쪽 정렬 메뉴' })
await right.click()
const rightMenu = page.getByRole('menu', { name: '오른쪽 정렬 메뉴' })
const [rb, rm] = [await box(right), await box(rightMenu)]
check('menuAlign end + menuWidth', Math.abs(rb.x + rb.width - (rm.x + rm.width)) <= 1 && Math.round(rm.width) === 220, `width ${Math.round(rm.width)}`)
await page.keyboard.press('Escape')
const area = page.locator('[data-context-area]')
const areaBox = await box(area)
await page.mouse.click(areaBox.x + 50, areaBox.y + 40, { button: 'right' })
const context = page.getByRole('menu', { name: '도면 메뉴' })
const contextBox = await box(context)
check('context menu opens at pointer', Math.abs(contextBox.x - (areaBox.x + 50)) <= 1 && Math.abs(contextBox.y - (areaBox.y + 40)) <= 1, `${Math.round(contextBox.x)},${Math.round(contextBox.y)}`)
await page.getByRole('menuitem', { name: '선택 확대' }).click()
check('context entry reported', (await log(page)).includes('context/zoom-selection'))
// Menu kept inside the viewport near the right edge.
await page.setViewportSize({ width: 900, height: 700 })
await page.mouse.click(0, 0)
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(100)
await page.locator('.sw-story-scroller').first().evaluate(element => { element.scrollLeft = element.scrollWidth })
await right.click()
const edge = await box(page.getByRole('menu', { name: '오른쪽 정렬 메뉴' }))
check('menu stays inside the viewport', edge.x >= 4 && edge.x + edge.width <= 900 - 4, `left ${Math.round(edge.x)}, right ${Math.round(edge.x + edge.width)}`)
await page.close()
}
{
// Responsive: More collects the collapsed items, no horizontal overflow, icon-only below 560.
const page = await newPage(1920, 1200)
await open(page, 'responsive')
const headers = page.locator('[data-width] .hm-sw-viewer-header')
const report = []
for (const [index, width] of [1440, 1100, 860, 520].entries()) {
const header = headers.nth(index)
const info = await header.evaluate(element => ({
overflow: element.scrollWidth - element.clientWidth,
objectOverflow: (() => { const object = element.querySelector('.hm-sw-viewer-header__object'); return object.scrollWidth - object.clientWidth })(),
more: !!element.querySelector('.hm-sw-viewer-header__more'),
labels: [...element.querySelectorAll('.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__switch-label')].map(item => item.textContent.trim() || item.getAttribute('aria-label')),
}))
report.push({ width, ...info })
const box2 = await box(header)
await page.screenshot({ path: path.join(out, `package-responsive-${width}.png`), clip: { x: box2.x, y: box2.y, width: box2.width, height: box2.height } })
}
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), JSON.stringify(report.map(item => [item.width, item.overflow, item.objectOverflow])))
check('1440: no More', !report[0].more)
check('1100: More, samples/tabs/switches moved', report[1].more && !report[1].labels.includes('샘플') && !report[1].labels.includes('2.5D') && report[1].labels.includes('레이어'))
check('860: panel toggles moved too', report[2].more && !report[2].labels.includes('레이어'))
check('520: 파일 열기 / 전체 보기 icon only', report[3].labels.includes('파일 열기') && !(await headers.nth(3).locator('.hm-sw-viewer-header__label', { hasText: '파일 열기' }).count()))
const more = headers.nth(2).getByRole('button', { name: '추가 도구' })
await more.click()
const moreMenu = page.getByRole('menu', { name: '추가 도구' })
const items = await moreMenu.locator('.hm-sw-viewer-menu__label, [role^=menuitem]').allInnerTexts()
check('More sections like the reference', ['샘플 도면', '도면 공간', '화면 보기', '패널'].every(label => items.includes(label)), items.join(' | '))
check('More face beige 260px', (await moreMenu.evaluate(element => `${getComputedStyle(element).backgroundColor} ${element.offsetWidth}`)) === 'rgb(245, 240, 229) 260')
await moreMenu.getByRole('menuitemcheckbox', { name: '레이어' }).click()
check('More checkbox reports command', (await log(page)).includes('layers=true'), await log(page))
await more.click()
await page.getByRole('menuitemradio', { name: /Layout/ }).click()
check('More radio selects tab value', (await log(page)).includes('space=layout1'))
await page.screenshot({ path: path.join(out, 'package-responsive-more-open.png') })
await page.close()
}
{
// Story captures (full page) for the report.
const page = await newPage(1920, 1000)
for (const id of ['viewer-header', 'menu-groups', 'states', 'dropdowns', 'settings-panel', 'content-variants', 'variable-size', 'responsive', 'logo', 'compare-with-sw-header']) {
await open(page, id)
await page.screenshot({ path: path.join(out, `story-${id}.png`), fullPage: true })
}
// Mode switch keeps state.
await open(page, 'compare-with-sw-header')
await page.getByLabel('뷰어 모드 (SWViewerHeader)').check()
const frame = page.locator('[data-viewer-frame]').last()
check('mode switch shows viewer header', (await frame.locator('.hm-sw-viewer-header').count()) === 1 && (await frame.locator('.hm-sw-header').count()) === 0)
await frame.getByRole('button', { name: '레이어', exact: true }).click()
await frame.getByRole('button', { name: '임시 로고 (일반 모드로) 홈' }).click()
check('brand click returns to normal SWHeader', (await frame.locator('.hm-sw-header').count()) === 1 && (await frame.locator('.hm-sw-viewer-header').count()) === 0)
await page.getByLabel('뷰어 모드 (SWViewerHeader)').check()
check('state kept across mode switch (layer panel still open)', (await frame.getByRole('dialog', { name: /레이어 \(/ }).count()) === 1)
await page.close()
}
{
// Logo: nothing drawn without one, image / element when given; layout unchanged.
const page = await newPage(1920, 1000)
await open(page, 'logo')
const cases = page.locator('[data-logo-case]')
const read = index => cases.nth(index).evaluate(element => {
const brand = element.querySelector('.hm-sw-viewer-header__brand'), doc = element.querySelector('.hm-sw-viewer-header__document')
const base = element.getBoundingClientRect()
return {
text: brand.textContent.trim(), img: !!brand.querySelector('img, .hm-sw-viewer-header__brand-mark svg'),
width: Math.round(brand.getBoundingClientRect().width), docX: Math.round(doc.getBoundingClientRect().x - base.x),
height: Math.round(element.querySelector('.hm-sw-viewer-header').getBoundingClientRect().height),
fileOpenY: Math.round(element.querySelector('.hm-sw-viewer-header__button').getBoundingClientRect().y - base.y),
}
})
const [none, src, node] = [await read(0), await read(1), await read(2)]
check('logo none: no HM / text / icon', none.text === '' && !none.img, JSON.stringify(none))
check('logo none: cap shrinks to its edge (no wide gap)', none.width === 24, JSON.stringify(none))
const logoWidth = await cases.nth(1).locator('.hm-sw-viewer-header__brand-mark').evaluate(element => Math.round(element.getBoundingClientRect().width))
check('logoSrc shows the image (cap = 24 + logo + 25, min 72)', src.img && src.width === Math.max(72, 24 + logoWidth + 25), JSON.stringify({ ...src, logoWidth }))
check('logo element shown', node.img && node.width >= 72, JSON.stringify(node))
check('header height and menu row unchanged with / without logo', [none, src, node].every(item => item.height === 52 && item.fileOpenY === none.fileOpenY), [none, src, node].map(item => item.height + '/' + item.fileOpenY).join(' '))
for (const [index, name] of [[0, 'none'], [1, 'src'], [2, 'element']]) {
const b = await box(cases.nth(index))
await page.screenshot({ path: path.join(out, 'package-logo-' + name + '.png'), clip: { x: b.x, y: b.y, width: 520, height: 52 } })
}
// Text / icon outline: same tokens as SWHeader, fill unchanged.
const outline = await page.evaluate(() => {
const header = document.querySelector('[data-logo-case] .hm-sw-viewer-header')
const style = selector => getComputedStyle(header.querySelector(selector))
return {
label: style('.hm-sw-viewer-header__label').textShadow, document: style('.hm-sw-viewer-header__document-name').textShadow,
tab: style('.hm-sw-viewer-header__tab').textShadow, caption: style('.hm-sw-viewer-header__switch-label').textShadow,
count: style('.hm-sw-viewer-header__count').textShadow,
icon: (() => {
const icon = header.querySelector('.hm-sw-viewer-header__icon')
const copy = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__outline svg *')), glyph = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__glyph svg *'))
// lucide 24-unit icon drawn at 16px: 1px each side = 1.5 units, so the copy is 2 + 3 units.
return { filter: getComputedStyle(icon).filter, copyStroke: copy.stroke, copyWidth: parseFloat(copy.strokeWidth), glyphStroke: glyph.stroke, glyphWidth: parseFloat(glyph.strokeWidth) }
})(),
labelColor: style('.hm-sw-viewer-header__label').color,
labelFont: [style('.hm-sw-viewer-header__label').fontSize, style('.hm-sw-viewer-header__label').fontWeight, style('.hm-sw-viewer-header__label').letterSpacing].join(' '),
}
})
const blacks = value => value.split('rgb(0, 0, 0)').length - 1
check('text outline 1.3px black on 14px text (label / file name / tab)', [outline.label, outline.document, outline.tab].every(value => value.includes('rgb(0, 0, 0) 1.3px 0px 0px') && blacks(value) === 16), outline.label.slice(0, 60))
check('label outline 1px black on 11px captions / counts', [outline.caption, outline.count].every(value => value.includes('rgb(0, 0, 0) 1px 0px 0px') && blacks(value) === 16), outline.caption.slice(0, 60))
check('icon outline: SVG stroke copy, no CSS filter', outline.icon.filter === 'none' && outline.icon.copyStroke === 'rgb(0, 0, 0)' && outline.icon.copyWidth === outline.icon.glyphWidth + 3 && outline.icon.glyphStroke !== 'rgb(0, 0, 0)', JSON.stringify(outline.icon))
check('fill / size / weight / spacing kept', outline.labelColor === 'rgb(255, 255, 255)' && outline.labelFont === '14px 500 normal', outline.labelColor + ' ' + outline.labelFont)
await page.close()
}
{
// SWHeader vs SWViewerHeader text / icon outline at 3x for the report.
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 }, deviceScaleFactor: 3 })
await open(page, 'compare-with-sw-header')
const sw = await box(page.locator('.hm-sw-header').first()), vw = await box(page.locator('.hm-sw-viewer-header').first())
await page.screenshot({ path: path.join(out, 'outline-swheader-3x.png'), clip: { x: sw.x, y: sw.y, width: 760, height: sw.height } })
await page.screenshot({ path: path.join(out, 'outline-viewer-3x.png'), clip: { x: vw.x, y: vw.y, width: 760, height: vw.height } })
await page.screenshot({ path: path.join(out, 'outline-viewer-right-3x.png'), clip: { x: vw.x + 1000, y: vw.y, width: 440, height: vw.height } })
const hovered = page.locator('.hm-sw-viewer-header').first().getByRole('button', { name: '레이어', exact: true })
await hovered.hover()
check('hover changes fill only (outline kept)', (await hovered.locator('.hm-sw-viewer-header__label').evaluate(element => getComputedStyle(element).textShadow)).includes('1.3px'))
await page.close()
const states = await browser.newPage({ viewport: { width: 1920, height: 900 }, deviceScaleFactor: 2 })
await open(states, 'states')
const header = await box(states.locator('.hm-sw-viewer-header'))
await states.getByRole('button', { name: '기본' }).hover()
await states.screenshot({ path: path.join(out, 'outline-states-2x.png'), clip: { x: header.x, y: header.y, width: 1440, height: header.height } })
const disabled = await states.getByRole('button', { name: 'Disabled', exact: true }).evaluate(element => [getComputedStyle(element).opacity, getComputedStyle(element.querySelector('.hm-sw-viewer-header__label')).textShadow.includes('1.3px')])
check('disabled fades text + outline together', disabled[0] === '0.6' && disabled[1] === true, JSON.stringify(disabled))
await states.close()
}
{
// Storybook sidebar: SW/Tool removed; SW/Popup (common SWPopup examples), SW/Header and SW/Viewer remain.
const index = await (await fetch(storybook + '/index.json')).json()
const titles = [...new Set(Object.values(index.entries).map(entry => entry.title).filter(title => title.startsWith('SW')))]
check('SW/Tool removed from the sidebar', !titles.includes('SW/Tool'), titles.join(', '))
check('SW/Popup, SW/Header and SW/Viewer remain', ['SW/Popup', 'SW/Header', 'SW/Viewer'].every(title => titles.includes(title)))
}
check('no page errors', errors.length === 0, errors.join(' | '))
await browser.close()
await writeFile(path.join(out, 'report.json'), JSON.stringify({ storybook, reference, comparison, results, errors }, null, 2) + '\n')
const failed = results.filter(item => item.result === 'FAIL').length
console.log(`\n${results.length - failed}/${results.length} PASS`)
process.exit(failed ? 1 : 0)