feat(sw): viewer header logo menu and file list; simplify SW Storybook

SW Viewer Header (approved optional props, public export names unchanged at 170):
- SWViewerHeader: logoMenu, documents, activeDocumentId, onDocumentSelect,
  onDocumentOpen, documentOpenLabel; measured responsive toggles (full ->
  icons -> More) with the SWHeader More / file-list styling.
- SW AI guides, ExampleH, viewer/text-weight/theme-inheritance tests and the
  logo-document-menu tests with their QA record.

SW Storybook cleanup (Story IDs and test fixtures kept):
- Sidebar: Overview -> "SW Header" (first), 전체 Footer -> "SW Footer",
  Footer/Viewer responsive pages -> "Responsive More", Viewer content-variants
  -> "Settings Popup" (merges the settings popup page).
- Hidden by the verification tag (direct URL kept): Header Dropdown,
  Document Open, Interactive States, Document Dropdown, Hidden Sub Controls,
  Home Menu standalone; Footer tool groups / custom content / parts;
  SW/Theme; Viewer menu groups / item states / variable size / settings
  panel / Header + Dropdown.
- Removed demo UI: Overview size and callback output (demo data and
  selection only with &verification), Home Menu Figma node table and width
  demo, Header Responsive More reduced to 1440 / 768 / 390, Popup reopen
  button, property-panel comparison captions, Viewer context-menu demo
  (&verification only).
- Tests: storybook-inventory records the hidden and renamed Stories;
  overview / range-cross-browser / viewer open the demo controls with
  &verification; integrated-popup reloads the Story instead of the reopen
  button; dropdown-consolidation asserts Dropdown is hidden from the sidebar
  and still opens by its direct URL.
This commit is contained in:
shkim4 committed 2026-10-21 14:52:46 +09:00
1 parent 46119eacb5
commit 23d522c136
57 files changed
+2623 -333

No files matched your search

+1 -1
View File
@@ -137,7 +137,7 @@ SW **뷰어 모드** 전용 Export입니다(2026-10-19 추가, 참고 DWG/DXF
| 컴포넌트 | 용도 | 주요 Props | 검증 |
|---|---|---|---|
| `SWViewerHeader` | 52px 뷰어 Header(로고 면 → 파일명 면 → 메뉴 그룹 → 오른쪽 끝), 반응형 More | `groups: SWViewerHeaderGroup[]`(필수), `onCommand(SWViewerCommand)`, `documentTitle`(`string \| null`), `documentPlaceholder`, `documentTooltip`, `document`, `logo`, `logoSrc`, `logoAlt`, `onHomeClick`, `trailing`, `moreLabel`, `toolbarLabel`, `<header>` 속성·`ref` | ◻ |
| `SWViewerHeader` | 52px 뷰어 Header(로고 면 → 파일명 면 → 메뉴 그룹 → 오른쪽 끝), 반응형 More. 로고 클릭 = 파일 열기 메뉴(`SWHeader` `...` 메뉴 모양), 파일명 클릭 = 문서 목록(`SWHeaderDocumentTitle`, + New 없음, `onDocumentOpen`이 있으면 아래 Open), 두 메뉴는 동시에 열리지 않음 | `groups: SWViewerHeaderGroup[]`(필수), `onCommand(SWViewerCommand)`, `documentTitle`(`string \| null`), `documentPlaceholder`, `documentTooltip`, `document`, `logoMenu`(`{ title?, items: { id, label, icon?, value?, checked?, disabled?, hidden?, onSelect? }[] }`, 기본 없음), `documents`(`SWHeaderDocument[]`), `activeDocumentId`, `onDocumentSelect`, `onDocumentOpen`(있을 때만 목록 아래 Open), `documentOpenLabel`(기본 `Open`), `logo`, `logoSrc`, `logoAlt`, `onHomeClick`, `trailing`, `moreLabel`, `toolbarLabel`, `<header>` 속성·`ref` | ◻ |
| `SWViewerHeaderButton` | `trailing`·`custom` 항목용 Header 버튼(정보 버튼 등) | `label`(필수), `icon`, `iconOnly`(`true` = 32 × 40, `"compact"` = 34 × 34), `pressed`(주면 토글), `button` 속성 | ◻ |
| `SWViewerMenu` | 단독 Dropdown·우클릭 메뉴(Header `type: "menu"`와 같은 메뉴) | `entries: SWViewerMenuEntry[]`, `open`, `onOpenChange`(필수), `anchorRef` 또는 `anchorPoint`, `align`(`start`/`end`), `width`, `surface`(`light` 기본 / `beige` = More 면), `label`, `onEntrySelect`, `initialFocus`(`first`/`menu`), `id`, `className` | ◻ |
| `SWViewerIcon` | 직접 그린 아이콘에 SW 아이콘 외곽선(SVG stroke) 적용 | `children`(필수), `className` | ◻ |
+8 -8
View File
@@ -135,23 +135,22 @@ SW **뷰어 모드**(도면 보기) 화면입니다. 모드 선택은 [AI_USAGE.
```tsx
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: sampleEntries },
] },
{ id: "space", label: "도면 공간", surface: "field", hidden: !fileName, items: [
{ type: "tabs", id: "space", label: "모델과 배치 선택", collapseBelow: 1180, value: space, onValueChange: setSpace, options },
] },
{ id: "view", label: "화면 보기", surface: "field", items: [
{ type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, disabled: !fileName },
// 실제 가용 폭: 공간이 있으면 표시 → 아이콘만 → More (숫자는 순서만 정함).
{ type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, collapseBelow: 1180, disabled: !fileName },
{ type: "switch", id: "depth", label: "2.5D", checked: depth, collapseBelow: 1180, onCheckedChange: setDepth },
] },
{ id: "panels", label: "패널", placement: "end", items: [
{ type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: optionsOpen, collapseBelow: 900, onPressedChange: setOptionsOpen },
{ type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: optionsOpen, iconOnlyBelow: 900, onPressedChange: setOptionsOpen },
] },
]
<SWViewerHeader documentTitle={fileName} groups={groups} onCommand={runCommand}
onDocumentOpen={openFile} documentOpenLabel="파일 열기"
documents={samples} activeDocumentId={sampleId} onDocumentSelect={loadSample}
trailing={<>
<SWViewerStatus state={fileName ? "ready" : "idle"} />
<SWViewerHeaderButton iconOnly icon={icons.info} label="정보" onClick={() => setAbout(true)} />
@@ -171,12 +170,13 @@ const groups: SWViewerHeaderGroup[] = [
| 구성 | 사용 |
|---|---|
| Header 메뉴 | `groups` 데이터: 파일(`button`·`menu`), 도면 공간(`tabs`, `surface: "field"`), 화면 보기(`button`·`switch`, `field`), 패널 토글(`toggle`, `placement: "end"`) |
| 파일 열기·샘플 | 로고 오른쪽 파일 열기 버튼 `onDocumentOpen`·`documentOpenLabel`(`SWHeader` `documentOpenPlacement="header"` 면 + 테두리 아이콘, 공간이 부족하면 아이콘만, More로 가지 않음), 파일명 클릭 `documents`·`activeDocumentId`·`onDocumentSelect`(`SWHeader` 파일명 목록, Open·+ New 없음) |
| Header 메뉴 | `groups` 데이터: 도면 공간(`tabs`, `surface: "field"`), 화면 보기(`button`·`switch`, `field`), 패널 토글(`toggle`, `placement: "end"`) |
| 오른쪽 끝 | `trailing`: `SWViewerStatus` + `SWViewerHeaderButton iconOnly` |
| 도구 창 | 비모달 `SWViewerPanel`(드래그·크기 조절·접기 기본). 여러 개면 `inset`·`defaultPosition`으로 겹치지 않게 |
| 확인·정보 | `SWViewerPanel modal` + `footer`의 `SWViewerButton variant="primary"` |
| 패널 내용 | `SWViewerSection` > `SWViewerField` > `SWViewerSwitch` / `SWViewerSlider` / `SWViewerInput`, 목록은 `surface="plain"` + 프로그램 목록, 설명 `SWViewerHint` |
| 반응형 | 참고값 `collapseBelow` 1180(샘플·도면 공간·2.5D) / 900(패널 토글), `iconOnlyBelow` 560(파일 열기·전체 보기) |
| 반응형 | 고정 너비가 아니라 Header의 실제 가용 폭으로 3단계: 기본(아이콘+텍스트) → `iconOnlyBelow` 항목 모두 아이콘만(툴팁 유지) → `collapseBelow` 항목을 하나씩 More로(값이 큰 것, 같으면 오른쪽 먼저). 넓어지면 역순 복귀. 패널 토글은 `collapseBelow` 없이 아이콘까지만 |
| 우클릭 메뉴 | `SWViewerMenu` + `anchorPoint` |
- 상태(파일명·패널 열림·탭 값)는 프로그램이 가지고, 명령은 각 Callback 또는 `onCommand` 한 곳에서 받는다. 도면 영역 높이·렌더러 크기는 앱 레이아웃 담당(VIEWER.md §7).
+10 -4
View File
@@ -36,12 +36,16 @@
| `type: "custom"` | `render()`, `menuEntries?`(More에 넣을 항목) |
| `onCommand(command)` | 모든 명령을 한 곳에서 받습니다: `{ id, itemId, value? }`. 항목 자체 Callback 다음에 호출 |
| `documentTitle`, `documentPlaceholder`, `documentTooltip`, `document?` | 파일명 면 |
| `documents?: SWHeaderDocument[]`, `activeDocumentId?`, `onDocumentSelect?(document)` | 파일명 클릭 시 아래 문서 목록(샘플 도면 등). `SWHeader` 파일명 목록(`SWHeaderDocumentTitle`)과 같은 모양·키보드(ArrowDown 열기, Escape, 바깥 클릭)이며 **Open·+ New는 표시하지 않음**. 목록 외곽 너비는 파일명 버튼 외곽 너비와 `SWHeader` 목록 너비(312px) 이상이며 긴 항목이면 넓어집니다. 열려도 로고가 위에 표시됨. 선택 시 프로그램이 파일을 불러오고 `documentTitle`·`activeDocumentId`를 갱신. `document`가 있으면 `document`가 우선 |
| `onDocumentOpen?()`, `documentOpenLabel?` | 로고 오른쪽 파일 열기 버튼(`SWHeader` `documentOpenPlacement="header"`와 같은 파일명 면 `SWToolButton appearance="document"` + `SWHeader` `...` 버튼 테두리의 폴더 아이콘 + `documentOpenLabel`, 기본 `Open`). 공간이 부족하면 아이콘만(라벨은 Tooltip·접근성 이름) 표시하고 More로 옮기지 않습니다. 실행 후 `onCommand({ id: "open", itemId: "document" })` |
| `logoMenu?: { title?, items }` | (2026-10-21 이후 뷰어 화면에서 사용하지 않음, 호환용) 로고 클릭 메뉴. 파일 열기는 `onDocumentOpen` 버튼을 씁니다. 항목 `{ id, label, icon?, value?, checked?, disabled?, hidden?, onSelect? }` |
| `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). 가로 스크롤은 만들지 않습니다.
- **반응형(2026-10-21, 실제 가용 폭 기준 3단계):** 고정 Breakpoint를 쓰지 않고 Header 행의 실제 남는 폭(로고·파일명·메뉴·Gap·Padding·More 버튼 포함, 파일명은 글자 폭까지만)을 측정합니다. ① 모두 들어가면 기본 형태(아이콘+텍스트), ② 부족하면 `icon`과 `iconOnlyBelow`가 있는 항목을 모두 아이콘만(라벨은 Tooltip·접근성 이름), ③ 그래도 부족하면 `collapseBelow` 항목을 하나씩 More(⋯, 베이지 260px Dropdown)로 옮깁니다(값이 큰 것부터, 같으면 오른쪽 먼저). 넓어지면 More → 아이콘 → 기본 순서로 복귀하고, 상단과 More에 같은 항목이 동시에 나오지 않습니다. `collapseBelow`·`iconOnlyBelow`의 숫자는 순서만 정합니다(Props 형식 동일). 파일 열기는 `logoMenu`, 샘플은 `documents`. 레이어·설정·속성은 `collapseBelow` 없이 아이콘까지만. 1180px 미만 간격 2px, 560px 미만 상태 글자 숨김(CSS container)은 그대로입니다. 가로 스크롤은 만들지 않습니다.
- More는 그룹/항목 단위 구획으로 자동 구성됩니다: menu·tabs는 자기 구획(`moreLabel`), button·toggle·switch는 그룹 구획(`group.moreLabel ?? group.label`). toggle·switch는 체크 항목, tabs는 라디오 항목이 됩니다.
- **Dropdown 색(2026-10-21):** Header 안의 Dropdown — 메뉴 버튼(`type: "menu"`), More(⋯), 로고 메뉴 — 은 크기·간격·글자는 `SWViewerMenu` 그대로, 색은 일반 `SWHeader`의 `...` 메뉴와 같습니다(면 #0e3d2f, Border `--hm-sw-header-object-border-color`, 그림자 0 4px 12px rgba(0,0,0,.5), 글자 `--hm-sw-header-text-color`, 보조 글자는 같은 색 80%, Hover `--hm-sw-header-hover`, 구분선 `--hm-sw-header-document-menu-divider-color`). 파일명 목록(`documents`)은 `SWHeader` 파일명 목록 모양을 유지하고, 단독 `SWViewerMenu`(우클릭 메뉴 등)는 기존 색 그대로입니다.
- **상태 표현:** 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`로 그리면 상태 색과 외곽선이 자동 적용됩니다.
@@ -82,9 +86,11 @@ 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 }] },
{ id: "panels", label: "패널", placement: "end", items: [{ type: "toggle", id: "options", label: "설정", icon: <OptionsIcon />, pressed: open, onPressedChange: setOpen, iconOnlyBelow: 900 }] },
]
<SWViewerHeader documentTitle={fileName} groups={groups} onCommand={runCommand} />
<SWViewerHeader documentTitle={fileName} groups={groups} onCommand={runCommand}
onDocumentOpen={openFile} documentOpenLabel="파일 열기"
documents={samples} activeDocumentId={sampleId} onDocumentSelect={loadSample} />
<SWViewerPanel title="옵션" open={open} onClose={() => setOpen(false)}>
<SWViewerSection title="표시">
<SWViewerField label="그리드">{id => <SWViewerSwitch id={id} checked={grid} onCheckedChange={setGrid} />}</SWViewerField>
@@ -97,7 +103,7 @@ const groups: SWViewerHeaderGroup[] = [
| 하지 말 것 | 이유 / 대신 |
|---|---|
| `<SWViewerHeader logo="HM" />`처럼 로고가 없다고 임의 문자·아이콘 넣기 | 로고는 프로그램이 정합니다. 없으면 생략 |
| 뷰어 화면에 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`, `SWHeader homeMenu`) 넣기 또는 비슷한 2단 메뉴 직접 만들기 | 홈 메뉴는 SW 일반 모드 전용입니다. 뷰어 로고 클릭은 `onHomeClick`, 파일 열기·샘플은 `groups`의 `button`·`menu` 항목 |
| 뷰어 화면에 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`, `SWHeader homeMenu`) 넣기 또는 비슷한 2단 메뉴 직접 만들기 | 홈 메뉴는 SW 일반 모드 전용입니다. 뷰어 로고의 파일 열기 메뉴는 `logoMenu`, 샘플 목록은 `documents`(직접 만든 Dropdown 금지) |
| 뷰어 Header를 `div` + `button`으로 다시 만들기, `hm-sw-viewer-*` class를 소비 코드에 붙이기 | 데이터(`groups`)와 `custom` / `trailing`으로 구성 |
| Header 버튼 글자에 `-webkit-text-stroke`·`border`로 외곽선 만들기 | 외곽선은 패키지 토큰이 이미 적용합니다 |
| 메뉴 색·간격을 `#123456`, `margin` 등 임의 값으로 덮기 | 토큰 `--hm-sw-viewer-*` / `--hm-sw-header-*`만 가장 바깥 SW 범위(`<SWTheme>` 또는 단독 컴포넌트 루트)의 `style`로 조정(기본값 유지 권장, [AI_USAGE.md](./AI_USAGE.md) §3.1) |
@@ -12,7 +12,6 @@ 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" />,
@@ -28,25 +27,23 @@ export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => vo
const [zoomSpeed, setZoomSpeed] = useState(1)
const [about, setAbout] = useState(false)
// File open: the header button after the logo (icon + label, icon only when the header is narrow). File name click:
// the sample list only (no Open / + New).
const openFile = () => onEvent?.("file:open")
const samples = [{ id: "sample-a", name: "BasicSample.dwg", title: "기본 도면 · DWG" }, { id: "sample-b", name: "simple.dxf", title: "기본 도형 · DXF" }]
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 },
// Measured steps: full while it fits, icon only next, then into More (the numbers only give the order).
{ type: "button", id: "fit", label: "전체 보기", icon: icons.fit, iconOnlyBelow: 560, collapseBelow: 1180, 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 }) },
{ type: "toggle", id: "layers", label: "레이어", icon: icons.layers, pressed: panels.layers, iconOnlyBelow: 900, onPressedChange: layers => setPanels({ ...panels, layers }) },
{ type: "toggle", id: "options", label: "설정", icon: icons.options, pressed: panels.options, iconOnlyBelow: 900, onPressedChange: options => setPanels({ ...panels, options }) },
] },
]
@@ -54,6 +51,7 @@ export function ExampleHViewerMode({ onEvent }: { onEvent?: (name: string) => vo
<>
{/* 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}
documents={samples} activeDocumentId={samples.find(sample => sample.name === fileName)?.id} onDocumentSelect={sample => setFileName(sample.name)} onDocumentOpen={openFile} documentOpenLabel="파일 열기"
onCommand={command => onEvent?.(`${command.itemId}/${command.id}${command.value === undefined ? "" : `=${String(command.value)}`}`)}
trailing={<>
<SWViewerStatus state={fileName ? "ready" : "idle"} />
@@ -1,4 +1,4 @@
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react"
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react"
import { useOutsidePointerDown } from "../../shared/use-outside-pointer"
import { SWHeaderKGuidePreview } from "./SWHeaderKGuide"
@@ -6,6 +6,8 @@ export interface SWHeaderMoreItem {
visible?: boolean
id: string
label: string
/** 16px icon left of the label (SWViewerHeader logo menu, 2026-10-21). SWHeader's own items have none. */
icon?: ReactNode
/** Current value shown at the right (e.g. the line style name). */
value?: string
/**
@@ -24,29 +26,73 @@ export interface SWHeaderMoreItem {
guide?: boolean
}
/** Props of a replacement trigger (`renderTrigger`): spread them on one button. */
export interface SWHeaderMoreTriggerProps {
ref: RefObject<HTMLButtonElement | null>
"aria-haspopup": "menu"
"aria-expanded": boolean
"aria-controls"?: string
onClick: () => void
}
export interface SWHeaderMoreProps {
items: SWHeaderMoreItem[]
/** Shows the trigger at any width (an item moved in by measurement, not by a breakpoint). */
forceVisible?: boolean
/**
* Internal reuse of the same menu with another trigger (SWViewerHeader logo menu, 2026-10-21). Default: the
* `...` button. With it, ArrowDown on the closed trigger opens the menu on its first item and a selected item
* returns focus to the trigger; the `...` button keeps its existing behavior.
*/
renderTrigger?: (props: SWHeaderMoreTriggerProps) => ReactNode
/** Title above the items, shown only when at least one item follows. Title and items are one group: no divider. */
title?: string
/** Accessible name of the menu when there is no `title`. Default `추가 도구`. */
label?: string
/** Controlled open state (menus that must not be open together). Leave undefined to let the component manage it. */
open?: boolean
onOpenChange?: (open: boolean) => void
/** Menu edge aligned to the trigger. Default `end` (the `...` button at the right). */
align?: "start" | "end"
className?: string
}
/** Overflow menu that keeps every moved control reachable. */
export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
const [open, setOpen] = useState(false)
export function SWHeaderMore({ items, forceVisible, renderTrigger, title, label = "추가 도구", open: openProp, onOpenChange, align = "end", className }: SWHeaderMoreProps) {
const [internalOpen, setInternalOpen] = useState(false)
const open = openProp ?? internalOpen
const setOpen = (next: boolean) => {
if (openProp === undefined) setInternalOpen(next)
onOpenChange?.(next)
}
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuId = useId()
const guideId = useId()
const guideRef = useRef<HTMLButtonElement>(null)
const titleId = useId()
const [guideOpen, setGuideOpen] = useState(false)
// Set when the keyboard opens a replacement trigger, so the first item gets focus once the menu renders.
const focusFirstRef = useRef(false)
useEffect(() => { if (!open) setGuideOpen(false) }, [open])
useOutsidePointerDown(open, rootRef, () => setOpen(false))
useEffect(() => {
if (open && focusFirstRef.current) {
focusFirstRef.current = false
visibleItems()[0]?.focus()
}
})
const visibleItems = () => Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[role^="menuitem"]:not(:disabled)') ?? []).filter(item => item.offsetParent !== null)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && open) {
if (renderTrigger && event.key === "ArrowDown" && !open && event.target === triggerRef.current) {
event.preventDefault()
focusFirstRef.current = true
setOpen(true)
} else if (event.key === "Escape" && open) {
event.stopPropagation()
setOpen(false)
triggerRef.current?.focus()
@@ -59,9 +105,18 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
}
}
const triggerProps: SWHeaderMoreTriggerProps = {
ref: triggerRef,
"aria-haspopup": "menu",
"aria-expanded": open,
"aria-controls": open ? menuId : undefined,
onClick: () => setOpen(!open),
}
const titled = !!title && items.some(item => item.visible !== false)
return (
<div ref={rootRef} className="hm-sw-header__more" data-forced={forceVisible || undefined} onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
<button
<div ref={rootRef} className={`hm-sw-header__more${className ? ` ${className}` : ""}`} data-forced={forceVisible || undefined} onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
{renderTrigger ? renderTrigger(triggerProps) : <button
ref={triggerRef}
type="button"
className="hm-sw-header__more-trigger"
@@ -69,16 +124,19 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={() => setOpen(value => !value)}
onClick={() => setOpen(!open)}
>
<svg width="16" height="4" viewBox="0 0 16 4" aria-hidden="true" focusable="false">
<circle cx="2" cy="2" r="1.6" fill="currentColor" />
<circle cx="8" cy="2" r="1.6" fill="currentColor" />
<circle cx="14" cy="2" r="1.6" fill="currentColor" />
</svg>
</button>
</button>}
{open && (
<div id={menuId} role="menu" aria-label="추가 도구" className="hm-sw-header__more-menu">
<div id={menuId} role="menu" aria-label={titled ? undefined : label} aria-labelledby={titled ? titleId : undefined}
className="hm-sw-header__more-menu" data-align={align === "start" ? "start" : undefined}>
{/* Title and its items are one group: no divider between them (dividers only separate groups). */}
{titled && <div id={titleId} className="hm-sw-header__more-title">{title}</div>}
{items.map(item => (
<button
key={item.id}
@@ -100,8 +158,10 @@ export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
onClick={() => {
item.onSelect?.()
setOpen(false)
if (renderTrigger) triggerRef.current?.focus()
}}
>
{item.icon && <span className="hm-sw-header__more-icon" aria-hidden="true">{item.icon}</span>}
<span>{item.label}</span>
{item.value && <span className="hm-sw-header__more-value">{item.value}</span>}
</button>
@@ -1,5 +1,9 @@
import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import { moveRovingFocus } from "../shared/roving-focus"
import { SWHeaderDocumentTitle } from "../sw-header/components/SWHeaderDocumentTitle"
import { SWHeaderMore } from "../sw-header/components/SWHeaderMore"
import type { SWHeaderDocument } from "../sw-header/types"
import { SWToolButton } from "../sw-tool/SWTool"
import { SWViewerMenu } from "./SWViewerMenu"
import { SWViewerSwitch } from "./SWViewerControls"
import { SWViewerIcon } from "./SWViewerIcon"
@@ -25,8 +29,42 @@ export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "
documentPlaceholder?: string
/** Tooltip of the file name. Default: the name, or `파일 열기 또는 샘플을 선택하세요`. */
documentTooltip?: string
/** Replaces the file-name text (the dark document well stays). */
/** Replaces the file-name text (the dark document well stays). Takes precedence over `documents`. */
document?: ReactNode
/**
* Logo menu (2026-10-21): the logo cap opens a file menu flush under the header (SWHeaderMore behavior: Escape,
* outside click, arrow keys; ArrowDown opens it on its first item and focus returns to the logo) with the face, colors,
* size, spacing and type of the other viewer header dropdowns (the More face). `title` adds a
* title row directly above the items (title + items are one group: no divider), shown only when an item follows;
* without a title only the items are listed. `icon`: 16px icon left of the label (SWViewerMenu icon slot).
* Each item runs its `onSelect`, then `onCommand` with `{ id, itemId: id }`. Takes precedence over `onHomeClick`;
* with no visible item the cap keeps the plain logo / `onHomeClick`. Never open together with the document list.
*/
logoMenu?: {
title?: string
items: { id: string; label: string; icon?: ReactNode; value?: string; checked?: boolean; disabled?: boolean; hidden?: boolean; onSelect?: () => void }[]
}
/**
* Document list below the file name (2026-10-21): the SWHeader file-name button and dropdown (SWHeaderDocumentTitle)
* without + New and Open (file open is the header button, `onDocumentOpen`). The button shows `documentTitle` (or `documentPlaceholder`); the list
* is at least as wide as the file-name button and the SWHeader list, wider for longer rows. Omit to keep the plain
* file name.
*/
documents?: SWHeaderDocument[]
/** Id of the current document in `documents` (its name in #FFC600; the list order never changes). */
activeDocumentId?: string
/** A document row was chosen. The host loads it and updates `documentTitle` / `activeDocumentId`. */
onDocumentSelect?: (document: SWHeaderDocument) => void
/**
* File open button in the header (2026-10-21), like SWHeader `documentOpenPlacement="header"`: the SWHeader file-name
* surface (SWToolButton `appearance="document"`) right after the logo, with a bordered folder icon and the label.
* Shown only with this callback; the document list never shows Open or + New in the viewer. When the header has no
* room it shows the icon only (label as tooltip and accessible name, measured like the other items), never in More.
* Runs the callback, then `onCommand` with `{ id: "open", itemId: "document" }`.
*/
onDocumentOpen?: () => void
/** Label of that button. Default `Open`. */
documentOpenLabel?: string
/** Menu data, in order. Groups with `placement="end"` form the trailing action area. */
groups: SWViewerHeaderGroup[]
/** Free content after the trailing groups (status, info button, ...). */
@@ -42,20 +80,64 @@ export interface SWViewerHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "
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 FolderIcon = () => (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></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)
/** Step id of the header Open button (icon only in stage 2). */
const OPEN_ID = ":document-open"
const isShown = (item: { id: string; hidden?: boolean }, collapsed: ReadonlySet<string>) => !item.hidden && !collapsed.has(item.id)
/**
* Measured responsive steps (2026-10-21), applied one at a time while the header has no room left and undone in reverse
* order once the room a step freed is back: 1. every item with an `icon` and `iconOnlyBelow` shows its icon only (label
* kept as tooltip and accessible name); 2. `collapseBelow` items move into More one by one, the higher value first and,
* for the same value, the rightmost first. No fixed width decides a step: the values only give the order.
*/
type Step = { kind: "icons"; ids: string[] } | { kind: "collapse"; id: string }
function responsiveSteps(groups: SWViewerHeaderGroup[], extraIcons: string[] = []): Step[] {
const icons: string[] = [...extraIcons], collapse: { id: string; rank: number; order: number }[] = []
groups.filter(group => !group.hidden).flatMap(group => group.items).forEach((item, order) => {
if (item.hidden) return
if (item.type !== "custom" && item.icon && !item.iconOnly && item.iconOnlyBelow !== undefined) icons.push(item.id)
if (item.collapseBelow !== undefined) collapse.push({ id: item.id, rank: item.collapseBelow, order })
})
collapse.sort((a, b) => b.rank - a.rank || b.order - a.order)
return [...(icons.length ? [{ kind: "icons" as const, ids: icons }] : []), ...collapse.map(item => ({ kind: "collapse" as const, id: item.id }))]
}
/**
* Room left in the header row (negative: missing) with every part at its natural width: the file name at its text
* width up to its usual width (so a short name leaves room to the menus), the object surface without stretching and
* the trailing area without its auto margin (`data-measuring`, sw-viewer.css). Read and reset in one layout effect,
* never painted; positioned layers (menus, logo menu) never count. Same method as SWHeader's measured More.
*/
function measureRoom(root: HTMLElement) {
root.setAttribute("data-measuring", "")
const style = getComputedStyle(root), box = root.getBoundingClientRect()
const end = box.right - parseFloat(style.borderRightWidth) - parseFloat(style.paddingRight)
let used = box.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft)
for (const child of Array.from(root.children)) {
const childStyle = getComputedStyle(child)
if (childStyle.display === "none" || childStyle.position === "absolute" || childStyle.position === "fixed") continue
used = Math.max(used, child.getBoundingClientRect().right + parseFloat(childStyle.marginRight))
}
root.removeAttribute("data-measuring")
return end - used
}
/** 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[] {
function moreEntries(groups: SWViewerHeaderGroup[], collapsedIds: ReadonlySet<string>, 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)
const collapsed = group.items.filter(item => !item.hidden && collapsedIds.has(item.id))
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) {
@@ -103,7 +185,8 @@ function MenuButton({ item, iconOnly, onCommand }: { item: Extract<SWViewerHeade
{!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}
{/* 2026-10-21: header dropdowns use the More face (beige), like every menu of the viewer header. */}
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} surface="beige" label={item.label} initialFocus={focus}
onEntrySelect={entry => onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} />
</>
)
@@ -143,15 +226,15 @@ function SwitchField({ item, onCommand }: { item: Extract<SWViewerHeaderItem, {
)
}
function Item({ item, width, onCommand }: { item: SWViewerHeaderItem; width: number; onCommand: (c: SWViewerCommand) => void }) {
function Item({ item, compact, onCommand }: { item: SWViewerHeaderItem; compact: boolean; onCommand: (c: SWViewerCommand) => void }) {
if (item.type === "custom") return <>{item.render()}</>
const iconOnly = !!item.icon && (item.iconOnly ? "always" : item.iconOnlyBelow !== undefined && width < item.iconOnlyBelow)
const iconOnly = !!item.icon && (item.iconOnly ? "always" : compact)
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}
<SWViewerHeaderButton label={item.label} icon={item.icon} iconOnly={iconOnly === "always" ? true : iconOnly ? "compact" : false} pressed={toggle ? item.pressed : undefined} title={item.title ?? (iconOnly ? item.label : undefined)} 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 }) }
@@ -192,19 +275,23 @@ export const SWViewerHeaderButton = forwardRef<HTMLButtonElement, SWViewerHeader
/**
* 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
* object surface holding data-driven menu groups, with a trailing action area. Responsive by the measured room of the
* header row, not by fixed widths (2026-10-21): full items while they fit, then icons only (`iconOnlyBelow` items with
* an icon), then `collapseBelow` items move into the More dropdown one by one; they come back in reverse order once
* the room is there. The numbers only set the order. 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,
logoMenu, documents, activeDocumentId, onDocumentSelect, onDocumentOpen, documentOpenLabel,
groups, trailing, onCommand, moreLabel = "추가 도구", toolbarLabel = "도면 작업 도구", className, ...rest
},
forwardedRef,
) {
const rootRef = useRef<HTMLElement | null>(null)
const [width, setWidth] = useState(1920)
// Header width: a resize re-renders, and the layout pass below measures again.
const [, setWidth] = useState(1920)
const gradientId = `hm-sw-viewer-brand-${useId().replace(/:/g, "")}`
useLayoutEffect(() => {
const root = rootRef.current
@@ -215,23 +302,73 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
observer.observe(root)
return () => observer.disconnect()
}, [])
const start = groups.filter(group => group.placement !== "end")
const end = groups.filter(group => group.placement === "end")
// Measured responsive steps (see responsiveSteps): `level` steps are applied. Each layout pass measures the room; a
// missing room applies the next step, and the last step is undone once the room it freed (measured right after it was
// applied) is there again. The passes are synchronous layout effects, so only the settled layout is painted.
const steps = responsiveSteps([...start, ...end], onDocumentOpen ? [OPEN_ID] : [])
const [level, setLevel] = useState(0)
const applied = steps.slice(0, level)
const collapsed = new Set(applied.flatMap(step => (step.kind === "collapse" ? [step.id] : [])))
const compact = new Set(applied.flatMap(step => (step.kind === "icons" ? step.ids : [])))
const freedRef = useRef<{ before: number; room?: number }[]>([])
// Steps or texts that change widths start the measurement over (file name, labels, items shown).
const layoutKey = JSON.stringify([documentTitle, documentPlaceholder, documentOpenLabel, !!onDocumentOpen, steps, groups.map(group => [group.hidden, group.items.map(item => [item.id, item.hidden, "label" in item ? item.label : ""])])])
const keyRef = useRef(layoutKey)
// Web fonts change text widths without a resize: measure again once they are loaded.
const [, setFontsLoaded] = useState(0)
useEffect(() => {
const fonts = typeof document === "undefined" ? undefined : document.fonts
if (!fonts) return
let live = true
const loaded = () => { if (live) setFontsLoaded(count => count + 1) }
fonts.addEventListener?.("loadingdone", loaded)
fonts.ready.then(loaded)
return () => { live = false; fonts.removeEventListener?.("loadingdone", loaded) }
}, [])
useLayoutEffect(() => {
const root = rootRef.current
if (!root) return
if (keyRef.current !== layoutKey) {
keyRef.current = layoutKey
freedRef.current = []
if (level !== 0) return setLevel(0)
}
if (level > steps.length) return setLevel(steps.length)
const room = measureRoom(root)
const freed = freedRef.current
const last = level > 0 ? freed[level - 1] : undefined
if (last && last.room === undefined) last.room = room - last.before
if (room < -0.5) {
if (level < steps.length) {
freed[level] = { before: room }
setLevel(level + 1)
}
} else if (level > 0 && (last?.room === undefined || room >= last.room)) {
freed.length = level - 1
setLevel(level - 1)
}
})
const command = (value: SWViewerCommand) => onCommand?.(value)
const owners = new Map<string, string>()
const more = moreEntries(groups, width, command, owners)
const more = moreEntries(groups, collapsed, command, owners)
const moreRef = useRef<HTMLButtonElement>(null)
const [moreOpen, setMoreOpen] = useState(false)
const [moreFocus, setMoreFocus] = useState<"first" | "menu">("menu")
// The logo menu and the document list are never open together (opening one closes the other).
const [openMenu, setOpenMenu] = useState<"logo" | "document" | null>(null)
const menuOpenChange = (menu: "logo" | "document") => (next: boolean) => setOpenMenu(current => (next ? menu : current === menu ? null : current))
const logoItems = logoMenu?.items.filter(item => !item.hidden) ?? []
const renderGroup = (group: SWViewerHeaderGroup) => {
const items = group.items.filter(item => isShown(item, width))
const items = group.items.filter(item => isShown(item, collapsed))
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} />)}
{items.map(item => <Item key={item.id} item={item} compact={compact.has(item.id)} 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 = (
@@ -253,11 +390,36 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
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>}
{logoItems.length > 0
? <SWHeaderMore className="hm-sw-viewer-header__logo-menu" align="start" title={logoMenu?.title} label={logoMenu?.title ?? (logoAlt ? `${logoAlt} 메뉴` : "메뉴")}
open={openMenu === "logo"} onOpenChange={menuOpenChange("logo")}
items={logoItems.map(item => ({
id: item.id, label: item.label, icon: item.icon, value: item.value, checked: item.checked, disabled: item.disabled, tier: "action" as const, visible: true,
onSelect: () => { item.onSelect?.(); command({ id: item.id, itemId: item.id }) },
}))}
renderTrigger={({ ref, ...trigger }) => (
<button ref={ref} type="button" className="hm-sw-viewer-header__brand" data-logo={mark ? "" : undefined} data-state={trigger["aria-expanded"] ? "open" : undefined}
aria-label={logoAlt ? `${logoAlt} 메뉴` : "메뉴"} title={logoAlt} {...trigger}>{brandContent}</button>
)} />
: 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>}
{onDocumentOpen && (() => {
const label = documentOpenLabel ?? "Open", iconOnly = compact.has(OPEN_ID)
return (
<SWToolButton appearance="document" className="hm-sw-viewer-header__open" data-document-open="" data-icon-only={iconOnly ? "" : undefined}
aria-label={iconOnly ? label : undefined} title={iconOnly ? label : undefined}
onClick={() => { onDocumentOpen(); command({ id: "open", itemId: "document" }) }}>
<span className="hm-sw-viewer-header__open-icon" aria-hidden="true"><FolderIcon /></span>
{!iconOnly && <span className="hm-sw-viewer-header__open-label">{label}</span>}
</SWToolButton>
)
})()}
<div className="hm-sw-viewer-header__document" role="group" aria-label="현재 도면" data-list={!documentNode && documents ? "" : undefined}>
{documentNode ?? (documents
? <SWHeaderDocumentTitle title={name} label="도면" documents={documents} activeDocumentId={activeDocumentId} onDocumentSelect={onDocumentSelect}
showNew={false} showOpen={false} menuOpen={openMenu === "document"} onMenuOpenChange={menuOpenChange("document")} />
: <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)}
+125
View File
@@ -114,6 +114,131 @@ button.hm-sw-viewer-header__brand { text-align: left; }
}
.hm-sw-viewer-header__document-name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
/* Logo menu (2026-10-21): the brand cap is the trigger of the SWHeader `...` menu (SWHeaderMore, sw.css). The wrapper
takes the cap's place in the row but is not positioned: the cap keeps its own layer (z-index 2 over the file name,
like the SWHeader logo), and the menu is placed by the header container itself — flush under the header's bottom
border (100% of its padding box + its 1px border) at the left edge of the cap, at any header height or width. */
.hm-sw-viewer-header__logo-menu.hm-sw-header__more {
position: static;
display: flex;
flex: 0 0 auto;
height: calc(var(--hm-sw-viewer-height) - 2px);
margin-left: 0;
}
.hm-sw-viewer-header__logo-menu > .hm-sw-header__more-menu {
top: calc(100% + 1px);
left: 0;
font-size: var(--hm-sw-header-font-size);
font-weight: 400;
line-height: normal;
}
/* Logo menu (2026-10-21): the size, spacing and type of the viewer header dropdowns (SWViewerMenu); its colors are
those of the SWHeader `...` menu it is (sw.css .hm-sw-header__more-menu: face, border, shadow, hover); its place
stays flush under the header. Only this menu: the document list stays the SWHeader file-name list. */
.hm-sw-viewer-header__logo-menu > .hm-sw-header__more-menu {
min-width: 160px;
padding: 4px;
border-width: 1px;
border-radius: var(--hm-sw-viewer-radius);
color: var(--hm-sw-header-text-color);
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item {
gap: 8px;
min-height: var(--hm-sw-viewer-menu-item-height);
padding: 0 12px;
color: inherit;
}
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item:is(:hover, :focus):not(:disabled) { background: var(--hm-sw-header-hover); outline: none; }
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item:disabled { opacity: 0.5; }
/* Title = SWViewerMenu label, directly above its items (one group, no divider); icons = SWViewerMenu icon slot. */
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-title { min-height: 0; padding: 6px 12px; font-size: 12px; font-weight: 500; line-height: 18px; }
/* Document list (2026-10-21): the SWHeader file-name button and dropdown (SWHeaderDocumentTitle) fill the well.
The button draws the well (border, gradient), sized to the viewer row; the text keeps the viewer inset. */
.hm-sw-viewer-header__document[data-list] {
--hm-sw-header-bar-height: calc(var(--hm-sw-viewer-height) - 2px);
padding: 0;
border: 0;
background: none;
font-size: var(--hm-sw-header-font-size);
line-height: normal;
}
/* Header Open (2026-10-21, `onDocumentOpen`): SWHeader's documentOpenPlacement="header" surface (SWToolButton
appearance="document", the file-name surface at its content width) right after the logo, sized to the viewer row,
with the label after a folder icon (no border, no face). Icon only when the header has no room (measured,
SWViewerHeader); the file name after it has no left padding (2026-10-21 user request). */
.hm-sw-viewer-header > .hm-sw-header__document[data-tool] {
--hm-sw-header-bar-height: calc(var(--hm-sw-viewer-height) - 2px);
font-size: var(--hm-sw-header-font-size);
line-height: normal;
}
.hm-sw-viewer-header .hm-sw-viewer-header__open { padding: 0 12px 0 16px; }
.hm-sw-viewer-header__open .hm-sw-header__document-name { display: inline-flex; align-items: center; gap: 8px; overflow: visible; }
.hm-sw-viewer-header__open-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: var(--hm-sw-viewer-button-height);
height: var(--hm-sw-viewer-button-height);
/* 2026-10-21 user request: no border and no face (transparent); the icon keeps its color, size and place. */
border: 0;
background: transparent;
box-shadow: none;
text-shadow: none;
}
.hm-sw-viewer-header__open-icon > svg { width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-header > .hm-sw-header__document[data-tool] + .hm-sw-viewer-header__document { padding-left: 0; }
.hm-sw-viewer-header__document[data-list] > .hm-sw-header__document { flex: 1 1 auto; min-width: 0; }
/* No file open button before it: the file name starts 10px after the slanted logo edge (at mid height), as in
SWHeader (text 10px after its home slant; measured 10.5px here with 18px). */
.hm-sw-viewer-header__document[data-list] .hm-sw-header__document-button { padding: 0 16px 0 18px; }
/* After the file open button (2026-10-21 user decision): 8px in. */
.hm-sw-viewer-header > .hm-sw-header__document[data-tool] + .hm-sw-viewer-header__document[data-list] .hm-sw-header__document-button { padding: 0 16px 0 8px; }
/* The list (SWHeader face, placement and rows unchanged) is never narrower than the file-name button it opens from:
its outer width is at least the button's (100% of the wrapper, plus the two borders the list starts left of it) and
at least the SWHeader list width; longer rows widen it. min-width wins over the SWHeader max-width. */
.hm-sw-viewer-header__document[data-list] .hm-sw-header__document-menu {
width: max-content;
min-width: max(
calc(var(--hm-sw-header-document-menu-width) + 2 * var(--hm-sw-header-document-menu-border-width)),
calc(100% + 2 * var(--hm-sw-header-document-menu-border-width))
);
}
/* Viewer header dropdown colors (2026-10-21): the menus of the header — menu buttons, More and their submenus — use the
colors of the SWHeader `...` menu (sw.css .hm-sw-header__more-menu): its face and shadow (fixed values there), the
object border token, the header text, secondary text at 80% (as its values) and the white 8% row hover with the text
unchanged. Size, spacing and type stay SWViewerMenu. A standalone SWViewerMenu (context menu) and the document list
keep their own colors. */
.hm-sw-viewer-header .hm-sw-viewer-menu[data-surface] {
border-color: var(--hm-sw-header-object-border-color);
background: #0e3d2f;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
color: var(--hm-sw-header-text-color);
}
.hm-sw-viewer-header .hm-sw-viewer-menu .hm-sw-viewer-menu__item:is(:focus, :hover, [data-state="open"]):not([aria-disabled]) {
background: var(--hm-sw-header-hover);
color: inherit;
}
.hm-sw-viewer-header .hm-sw-viewer-menu :is(.hm-sw-viewer-menu__label, .hm-sw-viewer-menu__shortcut, .hm-sw-viewer-menu__chevron) { color: inherit; opacity: 0.8; }
.hm-sw-viewer-header .hm-sw-viewer-menu .hm-sw-viewer-menu__separator { background: var(--hm-sw-header-document-menu-divider-color); }
/* Measured responsive steps (2026-10-21, SWViewerHeader measureRoom): set only inside one layout pass, never painted.
Every part at its natural width: the file name at its text width up to its usual flex basis, the object surface
neither stretched nor shrunk, the trailing area without its auto margin. */
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__document { flex: 0 0 auto; width: max-content; max-width: clamp(140px, 25cqw, 360px); }
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__object { flex: 0 0 auto; }
.hm-sw-viewer-header[data-measuring] .hm-sw-viewer-header__actions { margin-left: 0; }
/* While the logo menu or the document list is open, the header takes the viewer dropdown layer (SWViewerMenu, 60) so
the parts hanging below it stay above SWViewerPanel popups (41+). Inside, the SWHeader order is kept: the logo cap
(z-index 2) over the file name and its list (z-index 1), so an open list never covers the logo. Closed: no layer. */
.hm-sw-viewer-header:has(> .hm-sw-viewer-header__logo-menu > .hm-sw-header__more-menu, > .hm-sw-viewer-header__document .hm-sw-header__document-menu) { z-index: 60; }
.hm-sw-viewer-header__object {
display: flex;
align-items: center;
+32
View File
@@ -48,6 +48,10 @@
--hm-sw-header-document-menu-inset-shadow: inset 0 4px 8px 0 rgba(0, 0, 0, 0.4);
--hm-sw-header-document-menu-divider-color: rgba(194, 217, 210, 0.2);
--hm-sw-header-document-list-max-height: 320px;
/* Document list scrollbar (2026-10-21): the Storybook sidebar scrollbar (.storybook/manager.css, #BDDFD4 thumb at
50% / 80% on hover, 6px, round, transparent track). */
--hm-sw-header-document-scrollbar-thumb: rgba(189, 223, 212, 0.5);
--hm-sw-header-document-scrollbar-thumb-hover: rgba(189, 223, 212, 0.8);
--hm-sw-header-document-action-background: rgba(182, 208, 201, 0.2);
--hm-sw-header-document-action-color: rgba(0, 0, 0, 0.7);
/* Figma hover variants: list 26:10680, + New 26:10762, Open 26:10805 */
@@ -422,6 +426,18 @@
overflow-y: auto;
box-shadow: var(--hm-sw-header-document-menu-inset-shadow);
}
/* Scrollbar (2026-10-21): like the Storybook sidebar — 6px, round thumb, transparent track 3px in from top and bottom,
thumb 50% (80% on the thumb). Shown whenever the list scrolls: Chromium does not repaint a custom scrollbar on the
list's own :hover, so a hover-only thumb could stay hidden. Chromium / Safari draw it with ::-webkit-scrollbar;
Firefox, which has no such pseudo-elements, uses the SW standard thin scrollbar with the same colors. Only the
scrollbar changes: list size, rows and scrolling stay as they are. */
@supports not selector(::-webkit-scrollbar) {
.hm-sw-header__document-list { scrollbar-width: thin; scrollbar-color: var(--hm-sw-header-document-scrollbar-thumb) transparent; }
}
.hm-sw-header__document-list::-webkit-scrollbar { width: 6px; background: transparent; }
.hm-sw-header__document-list::-webkit-scrollbar-track { margin: 3px 0; background: transparent; }
.hm-sw-header__document-list::-webkit-scrollbar-thumb { border-radius: 9px; background: var(--hm-sw-header-document-scrollbar-thumb); }
.hm-sw-header__document-list::-webkit-scrollbar-thumb:hover { background: var(--hm-sw-header-document-scrollbar-thumb-hover); }
/* Row = select button + optional Close Document button (siblings). Hover and the current
document are styled on the row so the overlay stays while the pointer is on the X button. */
.hm-sw-header__document-row {
@@ -1310,6 +1326,22 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-header__more-item[role="menuitemcheckbox"]::after { content: ""; width: 14px; text-align: center; }
.hm-sw-header__more-item[aria-checked="true"]::after { content: "✓"; }
.hm-sw-header__more-value { font-size: var(--hm-sw-header-label-size); opacity: 0.8; }
/* Optional title and item icons (SWViewerHeader logo menu, 2026-10-21). The title is rendered only with at least one
item and has no divider (title + items are one group); the `...` menu of SWHeader passes neither and is unchanged. */
.hm-sw-header__more-title {
display: flex;
align-items: center;
min-height: 32px;
padding: 0 10px;
font-weight: 700;
white-space: nowrap;
opacity: 0.8;
}
.hm-sw-header__more-item:has(> .hm-sw-header__more-icon) { justify-content: flex-start; }
.hm-sw-header__more-icon { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 16px; width: 16px; height: 16px; }
.hm-sw-header__more-icon > svg { width: 16px; height: 16px; }
.hm-sw-header__more-item:has(> .hm-sw-header__more-icon) > .hm-sw-header__more-value { margin-left: auto; }
.hm-sw-header__more-menu[data-align="start"] { right: auto; left: 0; }
/* Window area: print / guide pill, window move, minimize, close. */
.hm-sw-header__window {
@@ -279,12 +279,17 @@ try {
check('Overview keeps one Header at 49px and 16:9 Stage', await page.locator('header.hm-sw-header').count() === 1 && await page.locator('[data-preview-stage]').evaluate(e => Math.abs(e.getBoundingClientRect().width / e.getBoundingClientRect().height - 16 / 9) < 0.001) && await page.locator('header.hm-sw-header').evaluate(e => e.getBoundingClientRect().height === 49))
await go('window-move-motion')
check('Window Move Motion retains only nine 3x steps', JSON.stringify(await page.locator('section > header h2').allTextContents()) === JSON.stringify(['단계별 진행 (3배 확대)']) && await page.locator('[data-motion-time]').count() === 9 && await page.locator('.sw-story-zoom').first().evaluate(e => getComputedStyle(e).zoom === '3'))
// 2026-10-21 (SW Storybook menu cleanup, user request): Dropdown left the sidebar by the verification tag (storybook-inventory
// verificationTaggedIds); its ID and direct URL stay. Read the sidebar with the Header group open (SW Header page),
// since opening the hidden Story itself leaves the group collapsed and would make an absent link trivially true.
await page.goto(`${base}/?path=/story/sw-header--overview`)
await page.locator('a[href="/?path=/story/sw-header--overview"]').waitFor()
const headerLinks = await page.locator('a[href^="/?path=/story/sw-header--"]').evaluateAll(list => list.map(a => a.getAttribute('href')))
check('manager hides the Dropdown link under the open Header group, no Composed Controls submenu', headerLinks.length > 0 && !headerLinks.includes('/?path=/story/sw-header--composed-controls') && await page.getByRole('link', { name: 'Dropdown', exact: true }).count() === 0 && await page.getByRole('link', { name: 'Composed Controls', exact: true }).count() === 0, headerLinks)
await page.goto(`${base}/?path=/story/sw-header--composed-controls`)
await page.locator('#storybook-preview-iframe').waitFor()
const dropdownLink = page.getByRole('link', { name: 'Dropdown', exact: true })
await dropdownLink.waitFor()
await dropdownLink.scrollIntoViewIfNeeded()
check('manager shows a single Dropdown link directly under Header, no Composed Controls submenu', await dropdownLink.count() === 1 && await dropdownLink.getAttribute('href') === '/?path=/story/sw-header--composed-controls' && await page.getByRole('link', { name: 'Composed Controls', exact: true }).count() === 0)
await page.frameLocator('#storybook-preview-iframe').locator('[data-composed-controls]').waitFor()
check('Dropdown direct URL still opens the Story (ID sw-header--composed-controls)', await page.frameLocator('#storybook-preview-iframe').locator('[data-composed-controls]').count() === 1)
check('retired Dropdown links absent in manager', await page.locator(retiredDropdownIds.map(id => `a[href*="${id}"]`).join(',')).count() === 0)
await page.screenshot({ path: path.join(out, 'composed-manager.png'), fullPage: true })
check('no browser console or page errors', report.errors.length === 0, report.errors)
@@ -83,7 +83,8 @@ export async function popupChecks({ page, goto, check, log, rect, out, report })
await page.keyboard.press('Escape')
// B was used last (its dropdown), so B is the top popup, not C (opened last).
check('several popups: Escape outside closes only the top popup (last used: B)', (await log()).endsWith('close(b)') && await popup('c').count() === 1, await log())
await page.locator('[data-reopen]').click()
// Reloading the Story reopens A, B and C (the page has no reopen button since 2026-10-21).
await goto('sw-popup--multiple-popups')
await popup('a').locator('.hm-sw-property__titlebar').click()
await page.locator('body').click({ position: { x: 5, y: 5 } })
await page.keyboard.press('Escape')
+1 -1
View File
@@ -68,7 +68,7 @@ try {
page = await browser.newPage({ viewport:{width:1440,height:1000} }); page.setDefaultTimeout(6000)
page.on('pageerror', e => report.errors.push(e.message)); page.on('console', m => { if(m.type() === 'error') report.errors.push(m.text()) })
const go = async(id='overview') => {
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story`)
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story${id === 'overview' ? '&verification' : ''}`)
await page.locator('.hm-sw-header,.hm-sw-theme').first().waitFor(); await page.evaluate(() => document.fonts.ready)
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))))
}
@@ -21,7 +21,7 @@ const FIREFOX = process.env.SW_FIREFOX_PATH ?? 'C:/Program Files/Mozilla Firefox
// The Firefox driver talks WebDriver BiDi over the global WebSocket (Node 22+; Node 20: --experimental-websocket).
if (typeof WebSocket === 'undefined') throw new Error('No global WebSocket: run with Node 22+ or `node --experimental-websocket` (Node 20).')
const { chromium } = loadPlaywright()
const STORY = `${base}/iframe.html?id=sw-header--overview&viewMode=story`
const STORY = `${base}/iframe.html?id=sw-header--overview&viewMode=story&verification`
const VIEW = { width: 1400, height: 1000 }
const sleep = ms => new Promise(r => setTimeout(r, ms))
await mkdir(out, { recursive: true })
+12 -1
View File
@@ -61,12 +61,23 @@ export const footerStoryIds = ['full-footer', 'tool-groups', 'figma-variants', '
export const viewerStoryIds = ['viewer-header', 'menu-groups', 'states', 'dropdowns', 'settings-panel', 'content-variants', 'variable-size', 'responsive', 'compare-with-sw-header', 'logo'].map(id => `sw-viewer--${id}`)
export const themeStoryIds = ['sw-theme--theme-inheritance']
// 2b0e1a2 sidebar simplification: ID and direct URL kept, hidden from the sidebar by the verification tag.
export const verificationTaggedIds = ['sw-header--window-move-motion', 'sw-header--k-guide']
// 2026-10-21 (SW Storybook menu cleanup, user request): Interactive States and Document Dropdown left the sidebar the
// same way; Overview is named "SW Header" and sorted first (preview storySort). menuCleanupHiddenIds lists every Story the
// cleanup hid (also the approved additions hidden-sub-controls / home-menu-standalone and three Footer pages).
export const verificationTaggedIds = ['sw-header--window-move-motion', 'sw-header--k-guide', 'sw-header--interactive-states', 'sw-header--document-dropdown', 'sw-header--composed-controls']
export const menuCleanupHiddenIds = ['sw-header--interactive-states', 'sw-header--document-dropdown', 'sw-header--hidden-sub-controls', 'sw-header--home-menu-standalone',
...['tool-groups', 'custom-content', 'parts'].map(id => `sw-footer--${id}`),
// Second pass (same day): SW/Theme hidden as a whole; Viewer settings-panel merged into content-variants.
'sw-header--document-open', 'sw-header--composed-controls', ...themeStoryIds, ...['menu-groups', 'states', 'dropdowns', 'variable-size', 'settings-panel'].map(id => `sw-viewer--${id}`)]
export const renamedEntries = { 'sw-header--overview': 'SW Header', 'sw-footer--full-footer': 'SW Footer',
'sw-footer--responsive': 'Responsive More', 'sw-viewer--content-variants': 'Settings Popup',
'sw-viewer--responsive': 'Responsive More' }
// Every approved SW Story ID added after the unused-demo removal, as the current index lists them.
export const approvedAddedStoryIds = [...integratedStoryIds.filter(id => !retiredToolIds.includes(id)), ...homeMenuSidebarIds,
...Object.values(movedHomeMenuIds).filter(id => !retiredHomeMenuCompareIds.includes(id)), ...footerStoryIds, ...viewerStoryIds, ...themeStoryIds]
// An earlier index entry with the approved tag / Popup move applied (2b0e1a2; the moved Story has no component path).
export const withApprovedEntryChanges = entry => {
if (renamedEntries[entry.id]) entry = { ...entry, name: renamedEntries[entry.id] }
if (verificationTaggedIds.includes(entry.id)) return { ...entry, tags: [...entry.tags, 'verification'] }
if (!movedToPopupIds.includes(entry.id)) return entry
const { componentPath, ...rest } = entry
+51 -8
View File
@@ -1,7 +1,8 @@
// 2026-10-20 SW text weight request: Bold only on the named texts, with the black text outline where asked.
// Header file name (700) · Composed Controls field values (700) vs SWHeader field values (400) · Footer 보조기능 / 명령어
// titles and category tabs (700, 전체보기 / Model · Layout / commands unchanged) · Viewer header file name, labels and
// Model / Layout tabs (700, More kept) · recent files name / path / preview title (700 + outline). Hover keeps the
// Model / Layout tabs (700, More kept; 2026-10-21 logo menu and sample list follow the SWHeader `...` menu / file-name
// list rules) · recent files name / path / preview title (700 + outline). Hover keeps the
// text in place. Usage: build Storybook first, then node packages/sw/tests/text-weight.browser.mjs [evidence-dir]
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
@@ -109,19 +110,61 @@ check('Footer: ◀ ▶ only when the categories overflow their area', (area.cont
await page.screenshot({ path: path.join(out, 'footer.png'), clip: { x: 0, y: 940, width: 1920, height: 160 } })
// ---------- 3. Viewer header ----------
// 2026-10-21 (approved restructure): 파일 열기 moved into the logo menu (the SWHeader `...` menu, SWHeaderMore), 샘플 into
// the file-name list (SWHeaderDocumentTitle) and 전체 보기 into More. The header keeps the Bold file name (now the
// SWHeader file-name button), 레이어 · 설정 · 속성 and Model / Layout; the two menus follow the SWHeader rules, measured
// on SWHeader itself below and compared row by row.
const typo = s => ({ weight: s.weight, size: s.size, family: s.family, outline: s.outline })
// SWHeader references: the responsive `...` menu rows and the file-name list rows.
await goto('sw-header--responsive-more')
const refTrigger = page.locator('.hm-sw-header__more-trigger').filter({ visible: true }).first()
await refTrigger.click(); await page.waitForTimeout(200)
const refMore = await styles('.hm-sw-header__more-menu .hm-sw-header__more-item > span:first-child')
check('SWHeader ... menu rows (reference): Regular 400 without outline', refMore.length > 0 && refMore.every(r => r.weight === '400' && r.outline === 'none'), refMore)
await page.keyboard.press('Escape')
await goto('sw-header--document-dropdown')
await page.locator('.hm-sw-header__document-button').first().click(); await page.waitForTimeout(200)
const refDocs = await styles('.hm-sw-header__document-menu .hm-sw-header__document-item-name')
check('SWHeader file-name list rows (reference): 14px + 1.3px outline, one weight', refDocs.length > 0 && refDocs.every(r => r.size === '14px' && r.outline === OUTLINE_14 && r.weight === refDocs[0].weight), refDocs)
// 2026-10-21: the logo menu takes the viewer dropdown type (SWViewerMenu, "상단 메뉴 + Dropdown"); its rows and label are the reference.
await goto('sw-viewer--dropdowns')
await page.getByRole('button', { name: '도면' }).click(); await page.waitForTimeout(200)
const refViewerItem = await styles('.hm-sw-viewer-menu .hm-sw-viewer-menu__text')
const refViewerLabel = await styles('.hm-sw-viewer-menu .hm-sw-viewer-menu__label')
check('SWViewerMenu rows / label (reference): 14px 400 and 12px 500, no outline', refViewerItem.length > 0 && refViewerItem.every(r => r.size === '14px' && r.weight === '400' && r.outline === 'none')
&& refViewerLabel.length > 0 && refViewerLabel.every(r => r.size === '12px' && r.weight === '500' && r.outline === 'none'), { refViewerItem: refViewerItem.slice(0, 2), refViewerLabel })
await page.keyboard.press('Escape')
report.reference = { more: refMore[0], documents: refDocs[0], viewerItem: refViewerItem[0], viewerLabel: refViewerLabel[0] }
await goto('sw-viewer--viewer-header')
// The 1440px Story frame at full width (as the viewer suites): the header has room for every label.
await page.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' }); await page.waitForTimeout(200)
const vh = page.locator('.hm-sw-viewer-header').first()
const vNames = await styles('.hm-sw-viewer-header >> nth=0 >> :is(.hm-sw-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab)')
const vNames = await styles('.hm-sw-viewer-header >> nth=0 >> :is(.hm-sw-header__document-name, .hm-sw-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab)')
report.viewer = vNames
const want = ['파일 열기', '샘플', '전체 보기', '레이어', '설정', '속성']
// 2026-10-21 measured steps: at 1440 the header has room, so 전체 보기 is a header label again (Bold like the others), and
// the file open button after the logo shows 파일 열기 on the SWHeader file-name surface (Bold + outline like the file name).
const want = ['C0060202-001-평면및종단면도.dwg', '파일 열기', '전체 보기', '레이어', '설정', '속성']
const labels = vNames.map(v => v.text)
check('Viewer: file name, 파일열기 · 샘플 · 전체보기 · 레이어 · 설정 · 속성, Model · Layout are 14px 700 + 1.3px outline', want.every(w => labels.includes(w)) && labels.some(t => t.startsWith('Model')) && labels.some(t => t.startsWith('Layout')) && vNames.every(v => v.weight === '700' && v.size === '14px' && v.outline === OUTLINE_14), vNames)
check('Viewer: file name, 파일 열기, 전체 보기 · 레이어 · 설정 · 속성, Model · Layout are 14px 700 + 1.3px outline', want.every(w => labels.includes(w)) && labels.some(t => t.startsWith('Model')) && labels.some(t => t.startsWith('Layout')) && vNames.every(v => v.weight === '700' && v.size === '14px' && v.outline === OUTLINE_14), vNames)
check('Viewer: 샘플 is no longer a header label', !labels.includes('샘플'), labels)
const counts = await styles('.hm-sw-viewer-header >> nth=0 >> .hm-sw-viewer-header__count')
check('Viewer: tab counts keep 11px 400', counts.length > 0 && counts.every(c => c.weight === '400' && c.size === '11px'), counts)
for (const t of ['파일 열기', '레이어']) {
const lab = vh.locator('.hm-sw-viewer-header__label', { hasText: t }).first()
await stableOnHover(`Viewer ${t}`, lab.locator('xpath=ancestor::button[1]'), lab)
}
// File open button (파일 열기, the logo menu is gone): hover keeps its label in place, size and weight.
const openButton = vh.locator('[data-document-open]')
await stableOnHover('Viewer 파일 열기', openButton, openButton.locator('.hm-sw-viewer-header__open-label'))
// File-name list (샘플): rows exactly as the SWHeader file-name list rows.
const docButton2 = vh.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button')
await stableOnHover('Viewer file name', docButton2, docButton2.locator('.hm-sw-header__document-name'))
await docButton2.click(); await page.waitForTimeout(150)
const sampleRows = await styles('.hm-sw-viewer-header >> nth=0 >> .hm-sw-header__document-item-name')
report.viewerSamples = sampleRows
check('Viewer sample list: 3 rows = SWHeader file-name list rows (weight / size / family / outline)', sampleRows.length === 3 && sampleRows.every(r => JSON.stringify(typo(r)) === JSON.stringify(typo(refDocs[0])) && !r.cut), { sampleRows, ref: refDocs[0] })
const sampleRow = vh.locator('.hm-sw-header__document-item').first()
await stableOnHover('Viewer sample row', sampleRow, sampleRow.locator('.hm-sw-header__document-item-name'))
await page.keyboard.press('Escape'); await page.waitForTimeout(100)
const lab = vh.locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).first()
await stableOnHover('Viewer 레이어', lab.locator('xpath=ancestor::button[1]'), lab)
const tab = vh.locator('.hm-sw-viewer-header__tab', { hasText: 'Layout' }).first()
await stableOnHover('Viewer Layout tab', tab, tab)
// More stays: narrow header shows the More button, it opens and lists the moved items.
+71 -12
View File
@@ -60,32 +60,90 @@ const fonts = (page, selector, exclude = null) => page.evaluate(([selector, excl
const only = (map, expected) => Object.keys(map).length > 0 && Object.keys(map).every(family => family === expected)
const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${family}`).join(' | ') || 'no text'
// More (2026-10-21): the header shows More only when its measured room runs out (full → icons → More). The Story's
// 1000px header fits as icons, so the header is narrowed for this check (as text-weight does), then restored.
async function openMore(page, area) {
const header = page.locator(`${area} .hm-sw-viewer-header`)
await header.evaluate(element => { element.style.width = "700px" })
await page.locator(`${area} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
}
const restoreWidth = (page, area) => page.locator(`${area} .hm-sw-viewer-header`).evaluate(element => { element.style.width = "" })
// Viewer header dropdown checked here (2026-10-21): the file-name list (.hm-sw-header__document-menu); the logo menu is gone.
const DOC_LIST = '.hm-sw-viewer-header .hm-sw-header__document-menu'
async function dropdownFonts(page, area, name, expected) {
const label = expected === DEFAULT ? 'Noto Sans KR' : 'Dotum'
await page.locator(`${area} .hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button`).click()
await page.waitForSelector(`${area} ${DOC_LIST}`)
const list = `${area} :is(${DOC_LIST}, .hm-sw-viewer-header .hm-sw-header__document-button)` // file name + file open surfaces
check(`${name}: 파일명 버튼 + 샘플 목록 = ${label}`, only(await fonts(page, list), expected), fmt(await fonts(page, list)))
await page.keyboard.press('Escape')
}
// Token → painted property of each dropdown part. 2026-10-21: the logo menu keeps the SWHeader `...` menu colors (object
// border, text, row hover tokens; its face #0e3d2f is a fixed value, checked below); the document list keeps its tokens.
const COLOR_TOKENS = [
['--hm-sw-header-document-menu-background', DOC_LIST, 'backgroundColor'],
['--hm-sw-header-document-menu-border-color', DOC_LIST, 'borderTopColor'],
['--hm-sw-header-title-color', `${DOC_LIST} .hm-sw-header__document-row:not([data-active]) .hm-sw-header__document-item-name`, 'color'],
['--hm-sw-header-document-item-hover-background', `${DOC_LIST} .hm-sw-header__document-row:not([data-active])`, 'backgroundColor', 'hover'],
]
async function dropdownColors(page, def, themed) {
// Paint of every part with its menu open (hovered first when the token is a hover state), next to the token value
// resolved in the same scope (a probe painted with var(token), so both are in the computed rgb() form).
const paint = async (area, scopeSelector) => {
const result = {}
for (const [token, part, property, hover] of COLOR_TOKENS) {
await page.locator(`${area} .hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button`).click()
const target = page.locator(`${area} ${part}`).first()
await target.waitFor()
if (hover) { await target.hover(); await page.waitForTimeout(120) }
result[token] = await target.evaluate((element, [token, property, scopeSelector]) => {
const probe = document.createElement('span'); probe.style.color = `var(${token})`
element.closest(scopeSelector).append(probe); const value = getComputedStyle(probe).color; probe.remove()
return { painted: getComputedStyle(element)[property], token: value }
}, [token, property, scopeSelector])
await page.mouse.move(2, 2)
await page.keyboard.press('Escape')
}
return result
}
const reference = await paint(def, '.hm-sw-viewer-header')
check('기본: 샘플 목록(SWHeader 파일명 목록 토큰) 배경·글자·Border·Hover = 기본 토큰 값', Object.values(reference).every(item => item.painted === item.token), JSON.stringify(reference))
// Themed: distinct colors set once on the outermost scope reach both menus; removed again afterwards.
const values = ['rgb(201, 11, 21)', 'rgb(202, 12, 22)', 'rgb(203, 13, 23)', 'rgb(204, 14, 24)']
const tokens = COLOR_TOKENS.map(item => item[0])
await page.locator(themed).evaluate((element, [tokens, values]) => tokens.forEach((token, i) => element.style.setProperty(token, values[i])), [tokens, values])
const themedPaint = await paint(themed, themed)
await page.locator(themed).evaluate((element, tokens) => tokens.forEach(token => element.style.removeProperty(token)), tokens)
check('뷰어 테마: 바깥 범위 토큰(배경·글자·Border·Hover)이 샘플 목록까지 상속', tokens.every((token, i) => themedPaint[token].painted === values[i]), JSON.stringify(themedPaint))
}
// ---------- 1. Theme inheritance Story ----------
{
const page = await newPage()
await open(page, 'sw-theme--theme-inheritance')
const scope = name => `[data-theme-case="${name}"]`
// Default (no theme): header, then its sample dropdown and More.
// Default (no theme): header, then its dropdowns and More. 2026-10-21: the header's dropdowns are the logo menu
// (파일 열기, SWHeader `...` menu) and the file-name sample list (SWHeader file-name list); the 샘플 menu button is gone.
const def = scope('default')
check('기본: 단독 SWViewerHeader = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-header`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-header`)))
await page.locator(`${def} .hm-sw-viewer-header [aria-haspopup="menu"]`).first().click()
await page.waitForSelector(`${def} .hm-sw-viewer-menu`)
check('기본: Dropdown = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-menu`)))
await page.keyboard.press('Escape')
await page.locator(`${def} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
await dropdownFonts(page, def, '기본', DEFAULT)
await openMore(page, def)
await page.waitForSelector(`${def} .hm-sw-viewer-menu[data-surface="beige"]`)
check('기본: More = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-menu`)))
await page.keyboard.press('Escape')
await restoreWidth(page, def)
// Viewer, themed through <SWTheme style>.
const viewer = scope('viewer-themed')
check('뷰어 테마: SWViewerHeader 전체 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-header`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-header`)))
await page.locator(`${viewer} .hm-sw-viewer-header [aria-haspopup="menu"]`).first().click()
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu`)
check('뷰어 테마: Dropdown = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
await page.keyboard.press('Escape')
await page.locator(`${viewer} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
await dropdownFonts(page, viewer, '뷰어 테마', VERIFY)
// Colors / border / hover of the two dropdowns: the SWHeader default token values without a theme, and a token set
// through `style` on the outermost SW scope (here the themed <SWTheme>) reaches both menus. The colors are set only
// for this check and removed again (the Story itself themes the font only).
await dropdownColors(page, def, viewer)
await openMore(page, viewer)
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu[data-surface="beige"]`)
check('뷰어 테마: More = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
// A submenu of More, if any, is a nested menu inside a nested menu.
@@ -96,6 +154,7 @@ const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${fa
check('뷰어 테마: More 서브메뉴 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
}
await page.keyboard.press('Escape')
await restoreWidth(page, viewer)
const panels = await page.locator(`${viewer} .hm-sw-viewer-panel`).count()
check('뷰어 테마: SWViewerPanel(제목·입력·버튼·스위치·힌트) = Dotum', panels === 2 && only(await fonts(page, `${viewer} .hm-sw-viewer-panel`), VERIFY), `${panels} panels · ${fmt(await fonts(page, `${viewer} .hm-sw-viewer-panel`))}`)
await page.screenshot({ path: path.join(out, 'viewer-themed-more.png'), clip: await page.locator(viewer).boundingBox() })
@@ -210,7 +269,7 @@ for (const width of [1920, 1440, 1180, 900, 560]) for (const id of ids) {
await page.addStyleTag({ content: legacy })
await page.waitForTimeout(150)
const viewer = '[data-theme-case="viewer-themed"]'
await page.locator(`${viewer} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
await openMore(page, viewer)
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu[data-surface="beige"]`)
const more = await fonts(page, `${viewer} .hm-sw-viewer-menu`)
const command = await fonts(page, '[data-theme-case="general-themed"] .hm-sw-footer-command')
@@ -0,0 +1,213 @@
// 2026-10-21 Firefox counterpart of viewer-logo-document-menu.browser.mjs: the installed Firefox over WebDriver BiDi (no
// Playwright build), real pointer clicks and key presses. Logo menu (SWHeaderMore) and file-name list
// (SWHeaderDocumentTitle) of SWViewerHeader: open / select / Escape / outside click / focus return / never together,
// title + divider rule, same face as the SWHeader `...` menu, responsive icon toggles and panels.
// Usage: node packages/sw/tests/viewer-logo-document-menu-firefox.browser.mjs <evidence-dir> [storybook-url]
import { mkdir, writeFile, rm } from 'node:fs/promises'
import { spawn } from 'node:child_process'
import os from 'node:os'
import path from 'node:path'
import { runtimeRequire } from './playwright-runtime.mjs'
const out = path.resolve(process.argv[2] ?? 'docs/sw/viewer-logo-document-menu-qa/firefox')
const base = process.argv[3] ?? 'http://127.0.0.1:6024'
const FIREFOX = process.env.SW_FIREFOX_PATH ?? 'C:/Program Files/Mozilla Firefox/firefox.exe'
const VIEW = { width: 1920, height: 1100 }
const sleep = ms => new Promise(r => setTimeout(r, ms))
await mkdir(out, { recursive: true })
const profile = path.join(os.tmpdir(), `sw-ff-viewer-menu-${process.pid}`)
await mkdir(profile, { recursive: true })
const port = 9300 + (process.pid % 500)
const proc = spawn(FIREFOX, ['--headless', '--no-remote', '--profile', profile, `--remote-debugging-port=${port}`], { stdio: 'ignore', env: { ...process.env, MOZ_HEADLESS_WIDTH: String(VIEW.width), MOZ_HEADLESS_HEIGHT: String(VIEW.height) } })
let ws
for (let i = 0; i < 60 && !ws; i++) {
await sleep(500)
try { ws = await new Promise((res, rej) => { const s = new WebSocket(`ws://127.0.0.1:${port}/session`); s.onopen = () => res(s); s.onerror = rej }) } catch { ws = undefined }
}
if (!ws) throw new Error('Firefox BiDi not reachable')
let id = 0; const pending = new Map(), errors = []
ws.onmessage = event => {
const m = JSON.parse(event.data)
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.type === 'error' ? rej(new Error(`${m.error}: ${m.message}`)) : res(m.result) }
// Storybook's own UI font (sb-common-assets) is served by the dev server only, not by a static build; the Chromium
// suites ignore such 404s too.
else if (m.method === 'log.entryAdded' && m.params.level === 'error' && !/sb-common-assets/.test(m.params.text)) errors.push(m.params.text)
}
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej: e => rej(new Error(method + ' -> ' + e.message)) }); ws.send(JSON.stringify({ id: i, method, params })) })
const session = await send('session.new', { capabilities: {} })
await send('session.subscribe', { events: ['log.entryAdded'] })
// A tab of our own: in current Firefox the first tree entry can be a privileged (chrome) context that BiDi scripts
// and viewport calls may not touch without -remote-allow-system-access.
const context = (await send('browsingContext.create', { type: 'tab' })).context
const viewport = async size => { try { await send('browsingContext.setViewport', { context, viewport: size }) } catch (e) { console.log('INFO setViewport:', e.message) } }
await viewport(VIEW)
const ev = async expr => { const r = await send('script.evaluate', { expression: expr, target: { context }, awaitPromise: true }); if (r.type === 'exception') throw new Error(r.exceptionDetails.text + ' :: ' + expr.slice(0, 160)); return r.result.value }
const goto = async story => {
await send('browsingContext.navigate', { context, url: `${base}/iframe.html?id=${story}&viewMode=story`, wait: 'complete' })
for (let i = 0; i < 40; i++) { if (await ev(`!!document.querySelector('.hm-sw-viewer-header')`)) break; await sleep(250) }
// Lift the Story page envelope like the Chromium suites; scroll instantly (the preview sets smooth scrolling).
await ev(`(() => { const s = document.createElement('style'); s.textContent = '.sb-page { --sb-page-max-width: 100vw; } html { scroll-behavior: auto !important; }'; document.head.append(s); return true })()`)
await ev('document.fonts.ready.then(() => true)'); await sleep(400)
}
const frames = () => ev('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))')
const pointer = actions => send('input.performActions', { context, actions: [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions }] })
// Click the center of the first visible match (scrolled into view first), with a real pointer.
const click = async selector => {
const point = JSON.parse(await ev(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.getClientRects().length)
el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); const r = el.getBoundingClientRect(); return JSON.stringify([r.x + r.width / 2, r.y + r.height / 2]) })()`))
await pointer([{ type: 'pointerMove', x: Math.round(point[0]), y: Math.round(point[1]), duration: 0 }, { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }])
await frames()
}
const clickAt = async (x, y) => { await pointer([{ type: 'pointerMove', x, y, duration: 0 }, { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }]); await frames() }
const KEYS = { ArrowDown: '\uE015', Escape: '\uE00C', Enter: '\uE007' }
const press = async key => { await send('input.performActions', { context, actions: [{ type: 'key', id: 'kbd', actions: [{ type: 'keyDown', value: KEYS[key] }, { type: 'keyUp', value: KEYS[key] }] }] }); await frames() }
const focus = selector => ev(`(document.querySelector(${JSON.stringify(selector)}).focus(), true)`)
const shot = async (file, selector, width, height) => {
const clip = JSON.parse(await ev(`(() => { const r = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return JSON.stringify({ x: Math.max(0, r.x - 8), y: Math.max(0, r.y - 8) }) })()`))
const result = await send('browsingContext.captureScreenshot', { context, clip: { type: 'box', ...clip, width, height } })
await writeFile(path.join(out, file), Buffer.from(result.data, 'base64'))
}
const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : String(JSON.stringify(d) ?? '').slice(0, 600)) }
const state = () => ev(`(() => {
const header = document.querySelector('.hm-sw-viewer-header'), logo = header.querySelector('button.hm-sw-viewer-header__brand')
const menu = header.querySelector('.hm-sw-header__more-menu'), list = header.querySelector('.hm-sw-header__document-menu')
const name = header.querySelector('.hm-sw-viewer-header__document .hm-sw-header__document-button'), active = document.activeElement
return JSON.stringify({ logoExpanded: logo?.getAttribute('aria-expanded'), menu: !!menu, list: !!list, nameExpanded: name?.getAttribute('aria-expanded'),
name: name?.textContent.trim(), focus: active === logo ? 'logo' : active === name ? 'name' : (active?.textContent ?? '').trim(),
log: document.querySelector('[data-callback-log]')?.textContent ?? '' })
})()`).then(JSON.parse)
// Face of an open menu and its first shown row, split into colors and the other metrics (2026-10-21 style unification:
// the logo menu and the document list take the metrics of the viewer dropdown, SWViewerMenu, and keep their colors).
const FACE = `(menu, itemSelector) => { const s = getComputedStyle(menu), item = [...menu.querySelectorAll(itemSelector)].find(e => e.offsetParent), i = getComputedStyle(item)
const m = menu.getBoundingClientRect(), b = item.getBoundingClientRect()
return { colors: { background: s.backgroundColor, border: s.borderTopColor, shadow: s.boxShadow, item: i.color },
metrics: { border: s.borderTopWidth + ' ' + s.borderTopStyle, radius: s.borderTopLeftRadius, padding: s.paddingTop + ' ' + s.paddingLeft, minWidth: s.minWidth, font: s.fontSize + '/' + s.lineHeight + ' ' + s.fontWeight,
itemHeight: Math.round(b.height), itemInset: Math.round(b.left - m.left), itemPadding: i.paddingLeft + ' ' + i.paddingRight, itemGap: i.columnGap, itemRadius: i.borderTopLeftRadius,
itemFont: i.fontFamily.split(',')[0] + ' ' + i.fontSize + '/' + i.lineHeight + ' ' + i.fontWeight } } }`
const faceOf = (menuSelector, itemSelector) => ev(`JSON.stringify((${FACE})(document.querySelector(${JSON.stringify(menuSelector)}), ${JSON.stringify(itemSelector)}))`).then(JSON.parse)
// Scrolled into view first (the 520 row sits at the bottom of the Story; its list with Open reaches below the fold).
const onTop = selector => ev(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.scrollIntoView({ block: 'nearest', behavior: 'instant' }); const r = el.getBoundingClientRect(); return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height - 6)) })()`)
try {
console.log('firefox', (await ev('navigator.userAgent')))
// ---------- SWHeader `...` menu reference ----------
await goto('sw-viewer--compare-with-sw-header')
await click('.hm-sw-header .hm-sw-header__more-trigger')
const reference = await faceOf('.hm-sw-header .hm-sw-header__more-menu', '.hm-sw-header__more-item')
const BOX = `element => { const s = getComputedStyle(element); return [s.borderTopWidth, s.borderTopStyle, s.borderTopColor, s.borderTopLeftRadius, s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor, s.boxShadow].join(' | ') }`
const refTrigger = await ev(`(${BOX})(document.querySelector('.hm-sw-header .hm-sw-header__more-trigger'))`)
check('SWHeader ... menu opens (reference colors)', !!reference.colors.background, reference)
await press('Escape')
check('SWHeader ... menu Escape closes, focus back on ...', await ev(`!document.querySelector('.hm-sw-header .hm-sw-header__more-menu') && document.activeElement?.classList.contains('hm-sw-header__more-trigger')`))
// ---------- SWViewerMenu reference metrics ("상단 메뉴 + Dropdown") ----------
await goto('sw-viewer--dropdowns')
await click('.hm-sw-viewer-header [aria-haspopup="menu"]')
const viewerMenu = await faceOf('.hm-sw-viewer-menu', '.hm-sw-viewer-menu__item')
check('SWViewerMenu reference opens (36px rows, 4px padding)', viewerMenu.metrics.itemHeight === 36 && viewerMenu.metrics.padding === '4px 4px', viewerMenu.metrics)
await press('Escape')
// ---------- File open button (the logo menu is gone) ----------
await goto('sw-viewer--viewer-header')
await click('.hm-sw-viewer-header .hm-sw-viewer-header__brand')
let s = await state()
check('logo opens no dropdown', !s.menu && !(await ev(`!!document.querySelector('[role=menu]')`)), s)
const open = JSON.parse(await ev(`(() => { const b = document.querySelector('.hm-sw-viewer-header [data-document-open]'), w = b.parentElement
return JSON.stringify({ surface: b.classList.contains('hm-sw-header__document-button') && w.matches('.hm-sw-header__document[data-tool]'), afterLogo: w.previousElementSibling?.classList.contains('hm-sw-viewer-header__brand'),
label: b.querySelector('.hm-sw-viewer-header__open-label')?.textContent, icon: b.querySelectorAll('.hm-sw-viewer-header__open-icon svg').length }) })()`))
check('file open button after the logo: SWHeader document surface, icon + 파일 열기', open.surface && open.afterLogo && open.label === '파일 열기' && open.icon === 1, open)
const iconBox = await ev(`(${BOX})(document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__open-icon'))`)
// 2026-10-21 user request: no border, transparent, no shadow.
check('file open icon: no border, transparent, no shadow', iconBox.startsWith('0px | none') && iconBox.includes('rgba(0, 0, 0, 0)') && iconBox.endsWith('none'), iconBox)
await click('.hm-sw-viewer-header [data-document-open]')
s = await state()
check('file open button runs the existing file open', s.log.endsWith('파일 선택창 (미연결) → document/open'), s)
await shot('firefox-header-open.png', '.hm-sw-viewer-header .hm-sw-viewer-header__brand', 640, 60)
// ---------- Document list ----------
// The logo cap (and its slanted tail) has the same pixels with the list open, as in SWHeader. Left out: the bottom
// 2px, which the list's own drop shadow (0 2px 4px: blur 4 − offset 2) reaches above the list (Firefox 2px, Chromium 1px).
const capClip = JSON.parse(await ev(`(() => { const r = document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__brand').getBoundingClientRect(); return JSON.stringify({ x: r.x, y: r.y, width: r.width + 18, height: r.height - 2 }) })()`))
const capShot = async () => (await send('browsingContext.captureScreenshot', { context, clip: { type: 'box', ...capClip } })).data
const n = JSON.parse(await ev(`(() => { const r = document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').getBoundingClientRect(); return JSON.stringify([r.x + r.width / 2, r.y + r.height / 2]) })()`))
await pointer([{ type: 'pointerMove', x: Math.round(n[0]), y: Math.round(n[1]), duration: 0 }]); await frames()
const capClosed = await capShot()
await click('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button')
check('open list never covers the logo cap (pixels identical)', capClosed === await capShot())
s = await state()
const list = JSON.parse(await ev(`(() => { const m = document.querySelector('.hm-sw-viewer-header .hm-sw-header__document-menu'), n = document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button')
const r = m.getBoundingClientRect(), b = n.getBoundingClientRect(), row = m.querySelector('.hm-sw-header__document-row').getBoundingClientRect()
return JSON.stringify({ rows: [...m.querySelectorAll('.hm-sw-header__document-item-name')].map(e => e.textContent.trim()), actions: m.querySelectorAll('.hm-sw-header__document-footer, .hm-sw-header__document-action').length,
active: m.querySelector('[data-active=true] .hm-sw-header__document-item-name')?.textContent, width: r.width, button: b.width, left: r.left - b.left, right: r.right - b.right, row: Math.round(row.height), gap: Math.round(r.y - b.bottom), background: getComputedStyle(m).backgroundColor }) })()`))
check('file name click opens the 3 samples only (no Open / + New), SWHeader face', s.list && s.nameExpanded === 'true' && list.rows.length === 3 && list.actions === 0 && list.row === 32 && Math.abs(list.gap) <= 1
&& list.background === 'rgb(16, 49, 39)' && list.active === 'C0060202-001-평면및종단면도.dwg', list)
check('document list >= file-name button (outer) and >= 312px, covers it', list.width + 0.5 >= list.button && list.width >= 311.5 && list.left <= 0.5 && list.right >= -0.5, list)
check('list painted on top (not cut)', await onTop('.hm-sw-viewer-header .hm-sw-header__document-menu'))
await shot('firefox-document-list.png', '.hm-sw-viewer-header .hm-sw-viewer-header__brand', 640, 200)
await click('.hm-sw-viewer-header .hm-sw-header__document-row:last-child .hm-sw-header__document-item')
s = await state()
check('sample select loads it, renames, closes, focus on the file name', s.name === 'simple.dxf' && s.log.includes('samples/sample-dxf') && !s.list && s.focus === 'name', s)
await press('ArrowDown')
s = await state()
check('ArrowDown opens the list on its first row', s.list && s.focus === 'BasicSample.dwg', s)
await press('Escape')
s = await state()
check('Escape closes the list and refocuses the file name', !s.list && s.focus === 'name', s)
// ---------- Above open panels ----------
await goto('sw-theme--theme-inheritance')
const themedArea = '[data-theme-case="viewer-themed"] .hm-sw-viewer-header'
await click(`${themedArea} .hm-sw-viewer-header__document .hm-sw-header__document-button`)
check('document list above an open SWViewerPanel', await onTop(`${themedArea} .hm-sw-header__document-menu`))
await press('Escape')
// ---------- Responsive ----------
await goto('sw-viewer--responsive')
const report = JSON.parse(await ev(`JSON.stringify([...document.querySelectorAll('[data-width] .hm-sw-viewer-header')].map(h => { const rect = s => h.querySelector(s).getBoundingClientRect()
const toggles = [...h.querySelectorAll('.hm-sw-viewer-header__button[data-kind=toggle]')], object = h.querySelector('.hm-sw-viewer-header__object'), open = h.querySelector('[data-document-open]')
return { width: Math.round(h.getBoundingClientRect().width), overflow: h.scrollWidth - h.clientWidth, objectOverflow: object.scrollWidth - object.clientWidth,
toggles: toggles.map(t => [t.getAttribute('aria-label') ?? t.textContent.trim(), !!t.querySelector('.hm-sw-viewer-header__label'), !!t.title]),
fit: [...object.querySelectorAll('button')].some(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === '전체 보기'), more: !!h.querySelector('.hm-sw-viewer-header__more'),
open: [!!open.querySelector('.hm-sw-viewer-header__open-label'), open.title, open.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect().width],
order: [rect('.hm-sw-viewer-header__brand').right, open.getBoundingClientRect().left, open.getBoundingClientRect().right, rect('.hm-sw-viewer-header__document').left, rect('.hm-sw-viewer-header__document').right, rect('.hm-sw-viewer-header__object').left] } }))`))
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.length === 4 && report.every(r => r.overflow <= 0 && r.objectOverflow <= 1), report.map(r => [r.width, r.overflow, r.objectOverflow]))
check('layer / settings / properties always in the header with tooltips', report.every(r => r.toggles.map(t => t[0]).join() === '레이어,설정,속성' && r.toggles.every(t => t[2])), report.map(r => [r.width, r.toggles]))
// 2026-10-21 measured steps: with the file open button this Story fits fully at 1440, as icons at 1100, More at 860 / 520.
check('1440 full (파일 열기 + 전체 보기 labels, no More); 1100 icons only, no More; 860 / 520 icons + More', report[0].fit && !report[0].more && report[0].open[0] && report[0].toggles.every(t => t[1])
&& !report[1].more && report[1].toggles.every(t => !t[1]) && report.slice(1).every(r => !r.open[0] && r.open[1] === '파일 열기' && Math.round(r.open[2]) === 34) && report[2].more && report[3].more,
report.map(r => [r.width, r.fit, r.more, r.open, r.toggles.map(t => t[1])]))
check('logo, file open, file name and toolbar never overlap', report.every(r => r.order[0] <= r.order[1] + 2.5 && r.order[2] <= r.order[3] + 0.5 && r.order[4] <= r.order[5] + 0.5), report.map(r => [r.width, r.order]))
await click('[data-width="520"] .hm-sw-viewer-header__document .hm-sw-header__document-button')
check('520: document list shown inside the header width', await onTop('[data-width="520"] .hm-sw-header__document-menu') && await ev(`(() => { const m = document.querySelector('[data-width="520"] .hm-sw-header__document-menu').getBoundingClientRect(), h = document.querySelector('[data-width="520"] .hm-sw-viewer-header').getBoundingClientRect(); return m.x >= h.x - 2 && m.right <= h.right })()`))
await press('Escape')
await click('[data-width="520"] [data-document-open]')
check('520: icon-only file open runs the file open', (await state()).log !== undefined && await ev(`document.querySelector('[data-callback-log]').textContent.endsWith('document/open')`))
// ---------- Narrow screen: icon toggles open the panels ----------
await viewport({ width: 820, height: 900 })
await goto('sw-viewer--viewer-header')
const panels = []
for (const [name, dialog] of [['레이어', '레이어 ('], ['설정', '옵션'], ['속성', '속성']]) {
const selector = `.hm-sw-viewer-header .hm-sw-viewer-header__button[aria-label="${name}"]`
await click(selector)
const after = JSON.parse(await ev(`(() => { const t = document.querySelector(${JSON.stringify(selector)}); return JSON.stringify({ pressed: t.getAttribute('aria-pressed'), color: getComputedStyle(t).color, title: t.title,
open: [...document.querySelectorAll('[role=dialog]')].some(d => (d.getAttribute('aria-label') ?? d.textContent).includes(${JSON.stringify(dialog)}) || document.getElementById(d.getAttribute('aria-labelledby'))?.textContent.includes(${JSON.stringify(dialog)})) }) })()`))
await click(selector)
after.closed = await ev(`document.querySelector(${JSON.stringify(selector)}).getAttribute('aria-pressed') === 'false'`)
panels.push({ name, ...after })
}
const width = await ev(`Math.round(document.querySelector('.hm-sw-viewer-header').getBoundingClientRect().width)`)
check(`narrow header (${width}px): icon toggles open / close panels, pressed #FFC600, tooltip`, width < 900 && panels.every(p => p.pressed === 'true' && p.color === 'rgb(255, 198, 0)' && p.title && p.open && p.closed), panels)
await shot('firefox-viewer-narrow.png', '.hm-sw-viewer-header', 820, 60)
} finally {
check('no console errors', errors.length === 0, errors.slice(0, 5))
const failed = checks.filter(c => !c.ok).length
await writeFile(path.join(out, 'viewer-logo-document-menu-firefox.json'), JSON.stringify({ base, checks, errors }, null, 2))
console.log(`${checks.length - failed}/${checks.length} PASS (firefox)`)
try { await send('session.end', {}) } catch {}
ws.close(); proc.kill()
await sleep(500); await rm(profile, { recursive: true, force: true }).catch(() => {})
process.exitCode = failed ? 1 : 0
}
@@ -0,0 +1,393 @@
// SW viewer header file open button + document list (2026-10-21).
// The logo cap opens the SWHeader `...` menu (SWHeaderMore) with Open; the file name opens the SWHeader document list
// (SWHeaderDocumentTitle, no + New / Open) with the samples. Never open together. Panel toggles stay in the header
// (icon only below 900px), 전체 보기 lives in More only.
// Usage: node packages/sw/tests/viewer-logo-document-menu.browser.mjs <storybook-url> <out-dir>
// e.g. node packages/sw/tests/viewer-logo-document-menu.browser.mjs http://127.0.0.1:6024 docs/sw/viewer-logo-menu-qa
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { runtimeRequire } from './playwright-runtime.mjs'
const require = runtimeRequire()
const { chromium } = require('playwright')
const [storybook, outArg] = process.argv.slice(2)
if (!storybook) throw new Error('Pass the Storybook URL')
const out = path.resolve(outArg ?? 'docs/sw/viewer-logo-menu-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 ? ` — ${typeof detail === 'string' ? detail : JSON.stringify(detail)}` : ''}`) }
const errors = []
const story = id => `${storybook}/iframe.html?id=${id}&viewMode=story`
const newPage = async (width = 1920, height = 1000) => {
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
}
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 box = async locator => locator.boundingBox()
const log = page => page.locator('[data-callback-log]').first().innerText()
// Not cut by a scroller or covered: the element at the center of the popup is the popup itself.
const onTop = locator => locator.evaluate(element => { element.scrollIntoView({ block: 'nearest' }); const r = element.getBoundingClientRect(); return element.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height - 6)) })
const focused = page => page.evaluate(() => document.activeElement?.getAttribute('aria-label') ?? document.activeElement?.textContent?.trim())
// Menu face of an open menu, split into colors and the other metrics (2026-10-21 style unification: the logo menu and
// the document list take the metrics of the viewer dropdown, SWViewerMenu, and keep their own colors).
const face = (locator, itemSelector, labelSelector, separatorSelector, iconSelector, textSelector) => locator.evaluate((menu, [itemSelector, labelSelector, separatorSelector, iconSelector, textSelector]) => {
// First item actually shown (SWHeader hides the items of wider tiers with CSS).
const s = getComputedStyle(menu), item = [...menu.querySelectorAll(itemSelector)].find(entry => entry.offsetParent !== null), i = getComputedStyle(item)
const label = labelSelector && menu.querySelector(labelSelector), separator = separatorSelector && menu.querySelector(separatorSelector)
const m = menu.getBoundingClientRect(), b = item.getBoundingClientRect()
return {
colors: { background: s.backgroundColor, border: s.borderTopColor, shadow: s.boxShadow, item: i.color, label: label ? getComputedStyle(label).color : null },
metrics: {
border: `${s.borderTopWidth} ${s.borderTopStyle}`, radius: s.borderTopLeftRadius, padding: s.padding, minWidth: s.minWidth, font: `${s.fontSize}/${s.lineHeight} ${s.fontWeight}`,
itemHeight: Math.round(b.height), itemInset: Math.round(b.left - m.left), itemPadding: `${i.paddingLeft} ${i.paddingRight}`, itemGap: i.columnGap, itemRadius: i.borderTopLeftRadius,
itemFont: `${i.fontFamily.split(',')[0]} ${i.fontSize}/${i.lineHeight} ${i.fontWeight}`, itemOutline: i.textShadow,
label: label ? (l => `${l.padding} ${l.fontSize}/${l.lineHeight} ${l.fontWeight}`)(getComputedStyle(label)) : null,
separator: separator ? (x => `${x.height} ${x.margin}`)(getComputedStyle(separator)) : null,
// Icon slot of the first row (2026-10-21 logo menu icon): size and left offset, then where the text starts.
icon: iconSelector && item.querySelector(iconSelector) ? (r => `${Math.round(r.width)}x${Math.round(r.height)} @${Math.round(r.left - b.left)}`)(item.querySelector(iconSelector).getBoundingClientRect()) : null,
textLeft: textSelector && item.querySelector(textSelector) ? Math.round(item.querySelector(textSelector).getBoundingClientRect().left - b.left) : null,
},
}
}, [itemSelector, labelSelector, separatorSelector, iconSelector, textSelector])
const MORE_ITEM = '.hm-sw-header__more-item', VIEWER_ITEM = '.hm-sw-viewer-menu__item'
// ---------- References: SWViewerMenu metrics ("상단 메뉴 + Dropdown") and the SWHeader `...` menu (colors, unchanged) ----------
let reference, viewerMenu, moreMenu, refTrigger
{
const page = await newPage()
await open(page, 'sw-viewer--dropdowns')
const trigger = page.getByRole('button', { name: '도면' })
await trigger.click()
const dropdown = page.getByRole('menu', { name: '도면', exact: true })
viewerMenu = await face(dropdown, VIEWER_ITEM, '.hm-sw-viewer-menu__label', '.hm-sw-viewer-menu__separator', '.hm-sw-viewer-menu__icon', '.hm-sw-viewer-menu__text')
const [d, t] = [await box(dropdown), await box(trigger)]
viewerMenu.place = { dy: Math.round((d.y - t.y - t.height) * 10) / 10, dx: Math.round((d.x - t.x) * 10) / 10 }
check('reference SWViewerMenu: 36px rows, 4px padding / radius, 14px 400, 4px under its trigger', viewerMenu.metrics.itemHeight === 36 && viewerMenu.metrics.padding === '4px' && viewerMenu.metrics.radius === '4px'
&& viewerMenu.metrics.font === '14px/20px 400' && viewerMenu.place.dy === 4 && viewerMenu.place.dx === 0, viewerMenu)
await page.close()
}
{
const page = await newPage()
await open(page, 'sw-viewer--compare-with-sw-header')
const header = page.locator('.hm-sw-header').first()
const more = header.getByRole('button', { name: '더보기' })
await more.click()
const menu = header.getByRole('menu', { name: '추가 도구' })
reference = await face(menu, MORE_ITEM)
refTrigger = await more.evaluate(element => { const s = getComputedStyle(element); return `${s.borderTopWidth} ${s.borderTopStyle} ${s.borderTopColor} | ${s.borderTopLeftRadius} | ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor} | ${s.boxShadow}` })
const responsive = await newPage(1920, 1500)
await open(responsive, 'sw-viewer--responsive')
await responsive.locator('[data-width="520"] .hm-sw-viewer-header').getByRole('button', { name: '추가 도구' }).click()
const more520 = responsive.getByRole('menu', { name: '추가 도구' })
moreMenu = await face(more520, VIEWER_ITEM, '.hm-sw-viewer-menu__label', '.hm-sw-viewer-menu__separator', '.hm-sw-viewer-menu__icon', '.hm-sw-viewer-menu__text')
const moreRow = more520.locator(VIEWER_ITEM).first()
await moreRow.hover()
await responsive.waitForTimeout(120)
moreMenu.hover = await moreRow.evaluate(element => `${getComputedStyle(element).backgroundColor} / ${getComputedStyle(element).color}`)
// 2026-10-21 final colors: the SWHeader `...` menu (measured above as `reference`, normal SW mode): face, border,
// shadow, text; its row hover (white 8%, text unchanged) is checked on the logo menu below.
const { label: _refLabel, ...refColors } = reference.colors, { label: moreLabel, ...moreColors } = moreMenu.colors
check('More (520px) colors = SWHeader ... menu (face, border, shadow, text); caption = text color', JSON.stringify(moreColors) === JSON.stringify(refColors) && moreLabel === moreColors.item && moreMenu.hover === `rgba(255, 255, 255, 0.08) / ${moreColors.item}`, { more: moreMenu.colors, hover: moreMenu.hover, swHeader: reference.colors })
check('header dropdown (상단 메뉴 + Dropdown) = More face and colors', JSON.stringify(viewerMenu.colors) === JSON.stringify(moreMenu.colors), { dropdown: viewerMenu.colors, more: moreMenu.colors })
await responsive.close()
const [menuBox, triggerBox] = [await box(menu), await box(more)]
check('SWHeader ... menu still right-aligned under its trigger (no title, no divider)', Math.abs(menuBox.x + menuBox.width - (triggerBox.x + triggerBox.width)) <= 1 && menuBox.y >= triggerBox.y + triggerBox.height
&& (await menu.locator('.hm-sw-header__more-title, .hm-sw-header__more-separator').count()) === 0)
await page.keyboard.press('Escape')
check('SWHeader ... menu Escape closes and refocuses ...', (await header.getByRole('menu').count()) === 0 && (await focused(page)) === '더보기')
// The normal file name keeps + New / Open.
await header.locator('.hm-sw-header__document-button').click().catch(() => {})
await page.close()
}
// ---------- Viewer header: file open button + document list ----------
{
const page = await newPage()
await open(page, 'sw-viewer--viewer-header')
const header = page.locator('.hm-sw-viewer-header').first()
const logo = header.locator('.hm-sw-viewer-header__brand')
const openButton = header.locator('[data-document-open]')
const name = header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button')
// Logo: no dropdown any more (2026-10-21, the file open moved to the header button).
await logo.click()
check('logo opens no dropdown (no menu trigger)', (await logo.getAttribute('aria-haspopup')) === null && (await page.getByRole('menu').count()) === 0)
// File open button: SWHeader documentOpenPlacement="header" surface right after the logo, bordered icon + label.
const layout = await header.evaluate(element => {
const button = element.querySelector('[data-document-open]'), wrapper = button.parentElement
return { surface: button.classList.contains('hm-sw-header__document-button') && wrapper.matches('.hm-sw-header__document[data-tool]'), afterLogo: wrapper.previousElementSibling?.classList.contains('hm-sw-viewer-header__brand'),
beforeName: wrapper.nextElementSibling?.classList.contains('hm-sw-viewer-header__document'), label: button.querySelector('.hm-sw-viewer-header__open-label')?.textContent, icons: button.querySelectorAll('.hm-sw-viewer-header__open-icon svg').length }
})
check('file open button after the logo: SWHeader document surface, icon + 파일 열기', layout.surface && layout.afterLogo && layout.beforeName && layout.label === '파일 열기' && layout.icons === 1, layout)
const iconBox = await openButton.locator('.hm-sw-viewer-header__open-icon').evaluate(element => { const s = getComputedStyle(element), r = element.getBoundingClientRect(); return { style: `${s.borderTopWidth} ${s.borderTopStyle} ${s.borderTopColor} | ${s.borderTopLeftRadius} | ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor} | ${s.boxShadow}`, size: [Math.round(r.width), Math.round(r.height)] } })
// 2026-10-21 user request: the icon has no border and no face (transparent); 34px slot kept.
check('file open icon: no border, transparent, no shadow; 34px slot', iconBox.style.startsWith('0px none') && iconBox.style.includes('rgba(0, 0, 0, 0)') && iconBox.style.endsWith('none') && iconBox.size.join() === '34,34', iconBox)
// File-name area (.hm-sw-viewer-header__document): no left padding, right after the file open button; the file-name
// button inside keeps its own padding (user decision 2026-10-21).
const nameStart = await header.evaluate(element => { const d = element.querySelector('.hm-sw-viewer-header__document'); return [getComputedStyle(d).paddingLeft, Math.round(d.getBoundingClientRect().left - element.querySelector('[data-document-open]').getBoundingClientRect().right)] })
check('file name area: no left padding, right after the file open button', nameStart[0] === '0px' && Math.abs(nameStart[1]) <= 2, nameStart)
check('no 샘플 header button, no + New', (await header.getByRole('button', { name: '샘플' }).count()) === 0 && (await page.getByText('+ New').count()) === 0)
await page.screenshot({ path: path.join(out, 'viewer-1440-idle.png'), clip: { ...(await box(header)), height: 56 } })
await openButton.click()
check('file open button runs the existing file open (onDocumentOpen + onCommand open/document)', (await log(page)).endsWith('파일 선택창 (미연결) → document/open'), await log(page))
// ---------- Document list ----------
check('file name is the SWHeader document button', (await name.getAttribute('aria-label')) === '도면: C0060202-001-평면및종단면도.dwg' && (await name.getAttribute('aria-expanded')) === 'false')
// The logo cap and its slanted tail have the same pixels with the list open, as in SWHeader. The cap's last pixel row
// touches the header's bottom border, where the list's own drop shadow (0 2px 4px) starts: that row is left out.
const cap = await box(logo), capClip = { x: cap.x, y: cap.y, width: cap.width + 18, height: cap.height - 1 }
await name.hover()
const capClosed = await page.screenshot({ clip: capClip })
await name.click()
const list = header.getByRole('group', { name: '문서 목록' })
await list.waitFor()
check('open list never covers the logo cap (pixels identical)', capClosed.equals(await page.screenshot({ clip: capClip })))
const rows = await list.locator('.hm-sw-header__document-item-name').allInnerTexts()
check('file name click opens the sample list', await list.isVisible() && await onTop(list) && JSON.stringify(rows) === JSON.stringify(['BasicSample.dwg', 'C0060202-001-평면및종단면도.dwg', 'simple.dxf']), rows)
check('no Open and no + New below the samples', (await list.locator('.hm-sw-header__document-footer, .hm-sw-header__document-action').count()) === 0)
check('current sample marked active', (await list.locator('[data-active="true"] .hm-sw-header__document-item-name').innerText()) === 'C0060202-001-평면및종단면도.dwg')
const listFace = await list.evaluate(element => { const s = getComputedStyle(element); return { background: s.backgroundColor, width: Math.round(element.getBoundingClientRect().width), row: Math.round(element.querySelector('.hm-sw-header__document-row').getBoundingClientRect().height) } })
check('SWHeader list face (32px rows, #103127, at least the SWHeader 312px)', listFace.background === 'rgb(16, 49, 39)' && listFace.width >= 312 && listFace.row === 32, listFace)
const [listBox, nameBox] = [await box(list), await box(name)]
check('list opens directly below the file name', Math.abs(listBox.y - (nameBox.y + nameBox.height)) <= 1, { list: listBox, name: nameBox })
await page.screenshot({ path: path.join(out, 'viewer-document-list-open.png'), clip: { x: cap.x - 8, y: cap.y - 8, width: 640, height: 200 } })
await list.getByRole('button', { name: 'simple.dxf' }).click()
check('sample select loads it and renames the file', (await name.innerText()).includes('simple.dxf') && (await log(page)).includes('samples/sample-dxf'), await log(page))
check('list closes after select, focus on the file name', (await list.count()) === 0 && await name.evaluate(element => element === document.activeElement))
await page.keyboard.press('ArrowDown')
check('ArrowDown opens the list on its first row', await list.isVisible() && (await focused(page)) === 'BasicSample.dwg')
await page.keyboard.press('Escape')
check('Escape closes the list and refocuses the file name', (await list.count()) === 0 && await name.evaluate(element => element === document.activeElement))
await name.click()
await page.mouse.click(900, 700)
check('outside click closes the list', (await list.count()) === 0)
// ---------- File open: icon only when the header has no room (measured), never in More ----------
const openState = width => header.evaluate(async (element, width) => {
element.style.width = `${width}px`
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
const button = element.querySelector('[data-document-open]'), icon = button.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect()
return { width, label: !!button.querySelector('.hm-sw-viewer-header__open-label'), aria: button.getAttribute('aria-label'), title: button.title, icon: [Math.round(icon.width), Math.round(icon.height)], overflow: element.scrollWidth - element.clientWidth }
}, width)
const sweep = []
for (let width = 1440; width >= 400; width -= 20) sweep.push(await openState(width))
const back = []
for (let width = 400; width <= 1440; width += 20) back.push(await openState(width))
const firstIcon = sweep.find(item => !item.label)
check('1440: icon + 파일 열기; narrower: icon only (tooltip / name 파일 열기, 34px icon slot kept)', sweep[0].label && firstIcon
&& sweep.filter(item => !item.label).every(item => item.aria === '파일 열기' && item.title === '파일 열기' && item.icon.join() === '34,34'), { first: sweep[0], firstIcon })
check('label comes back at the same widths when the header grows; no overflow', sweep.every(item => back.find(other => other.width === item.width).label === item.label) && [...sweep, ...back].every(item => item.overflow <= 0))
await header.evaluate(element => { element.style.width = '520px' })
await page.waitForTimeout(60)
const more = header.getByRole('button', { name: '추가 도구' })
await more.click()
const moreItems = await page.getByRole('menu', { name: '추가 도구' }).locator('[role^=menuitem]').allInnerTexts()
check('file open never moves into More', !moreItems.some(item => item.includes('파일 열기') || item === 'Open'), moreItems)
await page.keyboard.press('Escape')
await openButton.click()
check('icon-only button runs the same file open', (await log(page)).endsWith('파일 선택창 (미연결) → document/open'), await log(page))
await page.screenshot({ path: path.join(out, 'viewer-520-open-icon.png'), clip: { ...(await box(header)), height: 56 } })
await page.close()
}
// ---------- Above open panels ----------
{
// A panel at the top-left of the drawing area lies under the document list; the open list must be painted above it
// (the header takes the viewer dropdown layer while it is open) and the header has no layer once it is closed.
const page = await newPage()
await open(page, 'sw-theme--theme-inheritance')
const area = page.locator('[data-theme-case="viewer-themed"]')
const header = area.locator('.hm-sw-viewer-header')
await header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click()
const list = header.getByRole('group', { name: '문서 목록' })
check('document list above an open SWViewerPanel', await onTop(list), await header.evaluate(element => getComputedStyle(element).zIndex))
await page.keyboard.press('Escape')
check('closed list drops back (no raised layer at rest), cap over the open button and file name', await header.evaluate(element => getComputedStyle(element).zIndex === 'auto'
&& getComputedStyle(element.querySelector('.hm-sw-viewer-header__brand')).zIndex === '2' && getComputedStyle(element.querySelector('.hm-sw-header__document')).zIndex === '1'))
await page.close()
}
// ---------- Responsive ----------
{
// Tall enough that the 520 row and its popups are inside the viewport (the Story page does not scroll them in).
const page = await newPage(1920, 1500)
await open(page, 'sw-viewer--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 => {
const rect = selector => element.querySelector(selector)?.getBoundingClientRect()
const toggles = [...element.querySelectorAll('.hm-sw-viewer-header__button[data-kind=toggle]')]
return {
overflow: element.scrollWidth - element.clientWidth,
objectOverflow: (() => { const object = element.querySelector('.hm-sw-viewer-header__object'); return object.scrollWidth - object.clientWidth })(),
toggles: toggles.map(item => ({ name: item.getAttribute('aria-label') ?? item.textContent.trim(), label: !!item.querySelector('.hm-sw-viewer-header__label'), title: item.title })),
fitShown: [...element.querySelectorAll('.hm-sw-viewer-header__object button')].some(item => (item.getAttribute('aria-label') ?? item.textContent.trim()) === '전체 보기'),
more: !!element.querySelector('.hm-sw-viewer-header__more'),
logoRight: rect('.hm-sw-viewer-header__brand')?.right, docLeft: rect('.hm-sw-viewer-header__document')?.left,
docRight: rect('.hm-sw-viewer-header__document')?.right, objectLeft: rect('.hm-sw-viewer-header__object')?.left,
}
})
report.push({ width, ...info })
const b = await box(header)
await page.screenshot({ path: path.join(out, `responsive-${width}.png`), clip: { x: b.x, y: b.y, width: b.width, height: b.height } })
}
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), report.map(item => [item.width, item.overflow, item.objectOverflow]))
check('layer / settings / properties always in the header', report.every(item => JSON.stringify(item.toggles.map(toggle => toggle.name)) === '["레이어","설정","속성"]'), report.map(item => item.toggles.map(toggle => toggle.name)))
// 2026-10-21 measured steps: full → icons only → More, by the measured room (no fixed width). With the file open button
// after the logo, this Story's content fits fully at 1440, as icons at 1100, and needs More at 860 / 520.
check('1440: full items with labels (전체 보기 shown), no More; 1100: icons only, no More', report.slice(0, 1).every(item => item.fitShown && !item.more && item.toggles.every(toggle => toggle.label))
&& !report[1].more && report[1].fitShown && report[1].toggles.every(toggle => !toggle.label && toggle.title), report.map(item => [item.width, item.fitShown, item.more]))
check('860: icons only (tooltips kept) + More', report[2].more && report[2].toggles.every(toggle => !toggle.label && toggle.title), report[2])
check('520: icons only, then More for what still does not fit', report[3].more && report[3].toggles.every(toggle => !toggle.label && toggle.title), report[3])
check('logo, file name and toolbar never overlap', report.every(item => item.logoRight <= item.docLeft + 2 + 0.5 && item.docRight <= item.objectLeft + 0.5), report.map(item => [item.width, item.logoRight, item.docLeft, item.docRight, item.objectLeft]))
// Every item is either in the header or in More, never both and never lost.
const more = headers.nth(3).getByRole('button', { name: '추가 도구' })
await more.click()
const moreMenu = page.getByRole('menu', { name: '추가 도구' })
const moreItems = (await moreMenu.locator('[role^=menuitem]').allInnerTexts()).map(text => text.split(' · ')[0])
const inHeader = await headers.nth(3).evaluate(element => [...element.querySelectorAll('.hm-sw-viewer-header__object :is(.hm-sw-viewer-header__button:not(.hm-sw-viewer-header__more), [role=radio], .hm-sw-viewer-header__switch-label)')]
.map(item => item.getAttribute('aria-label') ?? item.textContent.trim().replace(/[\d,]+$/, '')))
const all = ['Model', 'Layout', '전체 보기', '2.5D', '어두운 배경', '레이어', '설정', '속성']
check('520: header + More hold every item exactly once (panel toggles never in More)', all.every(name => inHeader.includes(name) !== moreItems.includes(name)) && !moreItems.some(item => ['레이어', '설정', '속성'].includes(item)), { inHeader, moreItems })
await page.keyboard.press('Escape')
// Icon toggle at 520 still reports pressed / commands.
const icon = headers.nth(3).getByRole('button', { name: '레이어', exact: true })
await icon.click()
check('icon toggle pressed + command at 520', (await icon.getAttribute('aria-pressed')) === 'true' && (await log(page)).includes('layers=true'), await log(page))
// Narrow: the document list stays inside the header width.
await headers.nth(3).locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click()
const narrowList = headers.nth(3).getByRole('group', { name: '문서 목록' })
const [listBox, headerBox] = [await box(narrowList), await box(headers.nth(3))]
check('520: document list shown, inside the header width', await onTop(narrowList) && listBox.x >= headerBox.x - 2 && listBox.x + listBox.width <= headerBox.x + headerBox.width, { list: listBox, header: headerBox })
await page.keyboard.press('Escape')
await page.close()
}
// ---------- Narrow screen: icon toggles open the panels ----------
{
const page = await newPage(820, 900)
await open(page, 'sw-viewer--viewer-header')
const header = page.locator('.hm-sw-viewer-header').first()
const width = Math.round((await box(header)).width)
const panels = []
for (const [name, dialog] of [['레이어', /레이어 \(/], ['설정', '옵션'], ['속성', '속성']]) {
const toggle = header.getByRole('button', { name, exact: true })
await toggle.hover()
const hover = await toggle.evaluate(element => getComputedStyle(element).backgroundColor)
await toggle.click()
const panel = page.getByRole('dialog', { name: dialog })
panels.push({ name, iconOnly: (await toggle.locator('.hm-sw-viewer-header__label').count()) === 0, title: await toggle.getAttribute('title'), hover, open: await panel.isVisible(), pressed: await toggle.getAttribute('aria-pressed'), color: await toggle.evaluate(element => getComputedStyle(element).color) })
await toggle.click()
panels.at(-1).closed = (await panel.count()) === 0 && (await toggle.getAttribute('aria-pressed')) === 'false'
}
check(`narrow header (${width}px): icon toggles with tooltip, hover, pressed #FFC600, panel open / close`, width < 900 && panels.every(item => item.iconOnly && item.title && item.hover === 'rgb(38, 102, 81)' && item.open && item.pressed === 'true' && item.color === 'rgb(255, 198, 0)' && item.closed), panels)
await page.screenshot({ path: path.join(out, `viewer-narrow-${width}.png`), clip: { ...(await box(header)), height: 56 } })
await page.close()
}
// ---------- Document list never narrower than the file-name button (2026-10-21) ----------
{
// Outer width of the open list vs the outer width of the file-name button (padding and border included), for a long
// and a short file name at several header widths, after a file change and while resizing with the list open. The list
// covers the button from its left edge, is at least the SWHeader 312px, and no row is cut (longer rows widen it).
const page = await newPage(1920, 900)
await open(page, 'sw-viewer--viewer-header')
const header = page.locator('.hm-sw-viewer-header').first()
const button = header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button'), list = header.getByRole('group', { name: '문서 목록' })
const measure = async label => {
const result = await header.evaluate(element => {
const b = element.querySelector('.hm-sw-viewer-header__document .hm-sw-header__document-button').getBoundingClientRect(), m = element.querySelector('.hm-sw-header__document-menu').getBoundingClientRect()
const cut = [...element.querySelectorAll('.hm-sw-header__document-item-name')].filter(name => name.scrollWidth > name.clientWidth + 0.5).map(name => name.textContent)
return { button: Math.round(b.width * 10) / 10, list: Math.round(m.width * 10) / 10, left: Math.round((m.left - b.left) * 10) / 10, right: Math.round((m.right - b.right) * 10) / 10, cut }
})
return { label, ...result }
}
const cases = []
for (const file of ['long', 'short']) {
if (file === 'short') {
await header.evaluate(element => { element.style.width = '' })
await button.click()
await list.getByRole('button', { name: 'simple.dxf' }).click()
}
for (const width of [1440, 1100, 860, 520, 400]) {
await header.evaluate((element, width) => { element.style.width = `${width}px` }, width)
await page.waitForTimeout(60)
await button.click()
cases.push(await measure(`${file}@${width}`))
await page.keyboard.press('Escape')
}
}
// Resize with the list open: the minimum follows the button.
await header.evaluate(element => { element.style.width = '1440px' })
await button.click()
for (const width of [1200, 900, 600]) {
await header.evaluate((element, width) => { element.style.width = `${width}px` }, width)
await page.waitForTimeout(60)
cases.push(await measure(`open-resize@${width}`))
}
await page.keyboard.press('Escape')
const bad = cases.filter(item => item.list + 0.5 < item.button || item.left > 0.5 || item.right < -0.5 || item.list < 312 - 0.5 || item.cut.length)
check('document list >= file-name button (outer), covers it, >= 312px, no row cut — long / short name, 1440–400, resize while open', bad.length === 0, { bad, cases })
console.log('INFO document list widths', JSON.stringify(cases.map(item => [item.label, item.button, item.list])))
await page.close()
}
// ---------- Measured steps over a width sweep (2026-10-21) ----------
{
// The header's own width is swept (10px steps) down and back up. Stage per width: 0 full, 1 icons only, 2+ More with
// n items. Icons always come before More; narrower never has fewer steps; the way back gives the same state at
// every width (no flicker, no stuck item); no overflow. A shorter file name moves every boundary to a narrower width.
const page = await newPage(1920, 900)
await open(page, 'sw-viewer--viewer-header')
const header = page.locator('.hm-sw-viewer-header').first()
const state = width => header.evaluate(async (element, width) => {
element.style.width = `${width}px`
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
const object = element.querySelector('.hm-sw-viewer-header__object')
const icons = [...element.querySelectorAll('.hm-sw-viewer-header__button[data-kind]:not(.hm-sw-viewer-header__more)')].filter(button => button.getAttribute('data-icon-only') !== 'always')
const shown = [...object.querySelectorAll(':is(.hm-sw-viewer-header__button:not(.hm-sw-viewer-header__more), .hm-sw-viewer-header__tabs, .hm-sw-viewer-header__switch-field)')].length
const iconOnly = icons.length > 0 && icons.every(button => !button.querySelector('.hm-sw-viewer-header__label'))
const anyIcon = icons.some(button => !button.querySelector('.hm-sw-viewer-header__label'))
const more = !!element.querySelector('.hm-sw-viewer-header__more')
return { width, shown, iconOnly, anyIcon, more, stage: more ? 2 : iconOnly ? 1 : 0, overflow: Math.max(element.scrollWidth - element.clientWidth, object.scrollWidth - object.clientWidth - 1) }
}, width)
const sweep = async () => {
const down = [], up = []
for (let width = 1440; width >= 400; width -= 10) down.push(await state(width))
for (let width = 400; width <= 1440; width += 10) up.push(await state(width))
return { down, up }
}
const boundaries = list => ({ icons: list.find(item => item.stage >= 1)?.width, more: list.find(item => item.stage === 2)?.width })
const long = await sweep()
check('sweep: no overflow at any width (400–1440)', [...long.down, ...long.up].every(item => item.overflow <= 0), [...long.down, ...long.up].filter(item => item.overflow > 0).map(item => item.width))
check('sweep: icons switch together and always before More', [...long.down, ...long.up].every(item => item.anyIcon === item.iconOnly && (!item.more || item.iconOnly)))
check('sweep: narrower never shows more (monotonic: full → icons → More, fewer items)', long.down.every((item, i) => i === 0 || (item.stage >= long.down[i - 1].stage && item.shown <= long.down[i - 1].shown)))
check('sweep: growing back gives the same state at every width (reverse order, no stuck item)', long.down.every(item => { const back = long.up.find(other => other.width === item.width); return back.stage === item.stage && back.shown === item.shown }))
check('sweep: 1440 full; each stage reached by measured room only', long.down[0].stage === 0 && long.down.at(-1).stage === 2, boundaries(long.down))
// A short file name: the same content needs less room, so each step happens at a narrower width.
await header.evaluate(element => { element.style.width = '' })
await header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click()
await header.getByRole('button', { name: 'simple.dxf' }).click()
const short = await sweep()
const [a, b] = [boundaries(long.down), boundaries(short.down)]
check('sweep: shorter file name moves the steps to narrower widths', b.icons < a.icons && b.more < a.more, { long: a, short: b })
await page.close()
}
check('no page errors', errors.length === 0, errors.slice(0, 5).join(' | '))
await writeFile(path.join(out, 'viewer-logo-document-menu-results.json'), JSON.stringify({ storybook, results, errors }, null, 2))
await browser.close()
const failed = results.filter(item => item.result === 'FAIL').length
console.log(`${results.length - failed}/${results.length} PASS`)
process.exit(failed ? 1 : 0)
+46 -35
View File
@@ -17,7 +17,7 @@ const browser = await chromium.launch({ headless: true, executablePath: process.
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 story = id => `${storybook}/iframe.html?id=sw-viewer--${id}&viewMode=story${id === 'dropdowns' ? '&verification' : ''}`
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}`))
@@ -69,7 +69,8 @@ if (reference && reference !== '-') {
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()
// 2026-10-21: the samples are the file-name list (SWHeaderDocumentTitle), see viewer-logo-document-menu.browser.mjs.
await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').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()
@@ -96,32 +97,34 @@ if (comparison.reference) {
// 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: '샘플' })
// Samples (2026-10-21): the file-name list (SWHeaderDocumentTitle) replaced the 샘플 menu button. Click opens, rows listed,
// Escape closes and returns focus; the logo menu (Open) is checked in viewer-logo-document-menu.browser.mjs.
const samples = page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button')
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 menu = page.getByRole('group', { name: '문서 목록' })
check('document list opens on click', await menu.isVisible())
check('document list has the 3 samples', (await menu.locator('.hm-sw-header__document-item').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')
check('list placed under the file name', menuBox.y >= triggerBox.y + triggerBox.height - 1, `list ${Math.round(menuBox.x)},${Math.round(menuBox.y)} name ${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-selected')) === 'true')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
check('ArrowDown moves focus in menu', (await page.evaluate(() => document.activeElement?.textContent)).includes('평면'))
check('ArrowDown moves focus in the list', (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')
check('Escape closes the list', !(await menu.isVisible().catch(() => false)))
check('Escape returns focus to the file name', await samples.evaluate(element => element === document.activeElement))
// Keyboard open + Enter select → file name changes, selection logged by the host.
await samples.press('ArrowDown')
check('ArrowDown opens with the first row focused', (await page.evaluate(() => document.activeElement?.classList.contains('hm-sw-header__document-item'))))
// 2026-10-21: no Open below the rows (file open is the header button); ArrowUp wraps to the last sample.
await page.keyboard.press('ArrowUp')
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))
check('Enter selects a sample (document changes)', (await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-name').innerText()) === 'simple.dxf')
check('onDocumentSelect reported', (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)
check('outside click closes the list', (await page.getByRole('group', { name: '문서 목록' }).count()) === 0)
// Tabs: arrow keys select.
const model = page.getByRole('radio', { name: /Model/ })
await model.focus()
@@ -131,10 +134,10 @@ if (comparison.reference) {
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)')
// Hover face (2026-10-21: 전체 보기 left the header row for More, so the panel toggle is hovered).
const hovered = page.getByRole('button', { name: '레이어', exact: true })
await hovered.hover()
check('hover face #266651', (await hovered.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()
@@ -272,7 +275,7 @@ if (comparison.reference) {
await page.close()
}
{
// Responsive: More collects the collapsed items, no horizontal overflow, icon-only below 560.
// Responsive: measured steps (full → icons only → More), no horizontal overflow; More collects the collapsed items.
const page = await newPage(1920, 1200)
await open(page, 'responsive')
const headers = page.locator('[data-width] .hm-sw-viewer-header')
@@ -290,18 +293,24 @@ if (comparison.reference) {
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: '추가 도구' })
// 2026-10-21: 파일 열기 / 샘플 left the header (logo menu, file-name list). Measured steps instead of fixed widths:
// full items while they fit, then icons only, then More; the panel toggles never move into More. With this Story's
// content: 1440 / 1100 full, 860 icons only, 520 icons + More.
// 2026-10-21: with the file open button after the logo, 1100 fits as icons (still no More) and 860 needs More.
check('1440 / 1100: every item in the header (전체 보기 included), no More', [report[0], report[1]].every(item => !item.more && ['전체 보기', '2.5D', '어두운 배경', '레이어'].every(name => item.labels.includes(name))))
check('860: icons only + More', report[2].more && !(await headers.nth(2).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
check('520: icons only + More; panel toggles kept as icons (label → aria-label)', report[3].more && ['레이어', '설정', '속성'].every(name => report[3].labels.includes(name))
&& !(await headers.nth(3).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
check('no 파일 열기 / 샘플 header buttons at any width', report.every(item => !item.labels.includes('파일 열기') && !item.labels.includes('샘플')))
const more = headers.nth(3).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))
check('More sections: 도면 공간, 화면 보기 (with 전체 보기); no 패널', ['도면 공간', '화면 보기', '전체 보기'].every(label => items.includes(label)) && !items.includes('패널'), items.join(' | '))
// 2026-10-21: the header dropdowns (More included) take the SWHeader `...` menu face (#0e3d2f); width unchanged.
check('More face (SWHeader ... menu #0e3d2f) 260px', (await moreMenu.evaluate(element => `${getComputedStyle(element).backgroundColor} ${element.offsetWidth}`)) === 'rgb(14, 61, 47) 260')
await moreMenu.getByRole('menuitemcheckbox', { name: '2.5D' }).click()
check('More checkbox reports command', (await log(page)).includes('2.5d=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'))
@@ -358,11 +367,13 @@ if (comparison.reference) {
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,
// 2026-10-21: the file name is the SWHeader document button when `documents` is given (same outline token).
label: style('.hm-sw-viewer-header__label').textShadow, document: style('.hm-sw-viewer-header__document-name, .hm-sw-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')
// A lucide (stroke) icon: since 2026-10-21 the first header icon is the More dots (fill), so take the 레이어 toggle.
const icon = header.querySelector('.hm-sw-viewer-header__button[data-kind="toggle"] .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) }