feat(sw): integrated improvement 0.2.0 (popup content, header open, tool buttons)

SW package 0.2.0 integrated improvement (Task A, steps 1-8). All new props are
optional; existing SW Stories are pixel-identical to the previous build (28/28).

- Build reproducibility: dist text normalized to LF, dist/build-info.json
  (commit, dirty, tool versions, no timestamp), packages/sw/.gitattributes
  (eol=lf). LF checkouts give identical dist and tarball.
- SWHeader: documentOpenPlacement ("menu" default | "header": Open right of
  the logo in the existing document-button look, reusing onDocumentOpen and
  documentOpenLabel, no duplicate Open in the dropdown), leading slot,
  hiddenControls sub-items documentNew / documentOpen / layerFreeze /
  layerLock. Open moves into More on measured overflow (with hysteresis).
- showNew / showOpen (SWHeaderDocumentTitle), showFreeze / showLock (layer
  menu, field, property window list).
- SWToolButton (text / pill / document) and SWToolGroup (radio selection),
  reusing the existing toggle, pill and file-name looks.
- SWPopup / SWPopupSection / SWPopupRow on the shared property window frame;
  SWHeaderPropertyPanel gains children, title, autoFocus and optional
  selection / properties / groups. Escape stack for several popups.
- Field dropdowns inside a popup use the shared fixed overlay
  (useSWFixedDropdownPosition), so the scrolling body no longer clips them.
- AI guide, examples E/F, README, Storybook (SW/Header/Options, SW/Tool,
  SW/Popup) and verification record docs/sw/sw-integrated-improvement-2026-10-19.md.
- Regression scripts rescoped for the intended changes (unused-removal 81/81,
  overview 52/54 with the two pre-existing historical assertions).

Known limitation (unresolved): tools / leading content (incl. SWToolGroup)
does not move into More. With a wide tool bar the configured fields shrink
instead; in Example F the document name and layer field reach 0px at 390px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shkim4andClaude Opus 5.5 committed 2026-10-19 14:49:19 +09:00
1 parent 5faa1f1ffe
commit 55179b4577
138 files changed
+17194 -162

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Package files are LF on every checkout so dist, ai/ and the tarball are byte-identical (core.autocrlf=true included).
* text=auto eol=lf
*.png binary
*.svg text eol=lf
+2
View File
@@ -1,5 +1,7 @@
# @hm-design/sw
> **0.2.0 통합 개선(2026-10-19):** 패키지에 포함된 AI 안내 [ai/AI_USAGE.md](./ai/AI_USAGE.md)·[카탈로그](./ai/COMPONENT_CATALOG.md)가 공개 API의 기준입니다. 추가: `SWPopup` / `SWPopupSection` / `SWPopupRow`(속성창 외형 + `children` 자유 콘텐츠, 여러 Popup Escape 스택, Popup 안 Dropdown 고정 Overlay), `SWHeaderPropertyPanel`의 `children`·`title`·`autoFocus`와 선택형 `selection`/`properties`/`groups`, `SWToolButton` / `SWToolGroup`(기존 글자 토글·pill·파일명 면 재사용), `SWHeader`의 `documentOpenPlacement`(로고 오른쪽 Open, 실측 기반 More 이동)·`leading`, `hiddenControls`의 `documentNew`·`documentOpen`·`layerFreeze`·`layerLock`, `showNew`/`showOpen`/`showFreeze`/`showLock`. 모두 선택 Props이며 기존 화면은 그대로입니다. 빌드는 줄바꿈 정규화로 재현 가능하고 `dist/build-info.json`에 출처 Commit·도구 버전을 남깁니다. 검증 기록: 저장소 `docs/sw/sw-integrated-improvement-2026-10-19.md`.
> **현재 기준 색인(2026-10-19 1차 정리):** 아래 날짜별 안내는 작성 당시 기록입니다. 현재 기준은 [사용 가이드](../../docs/sw/sw-header-usage.md), [디자인 명세](../../docs/sw/sw-header-design-spec.md) 상단 색인, 속성 필드 13개 [검증 기록](../../docs/sw/sw-property-fields-verification-2026-10-19.md), 게이지·속성Bar [후속 4건 기록](../../docs/sw/sw-header-followup-four-design-fixes-verification-2026-10-19.md)·[Firefox 게이지 기록](../../docs/sw/sw-header-firefox-range-close-verification-2026-10-19.md)입니다. Storybook은 `SW / Header` 33개 Story이며 전체 목록과 2차 검토 대상은 [1차 정리 보고](../../docs/sw/sw-first-pass-cleanup-review-2026-10-19.md)에 있습니다.
> **3차 구현(2026-10-16):** `hiddenControls`(로고 외 모든 구성요소 개별 제거, 로고 필수), `menuOrder`(레이어·색상·선/문자/치수스타일 순서), 속성창 `shapeContent` Slot, 속성창 4상태 개별·비교 Story를 추가했습니다. 미지정 시 기존 디자인과 같습니다. [사용법](../../docs/sw/sw-header-usage.md#구성요소-표시메뉴-순서속성창-4상태형상-slot-2026-10-16-3차), [3차 검증 기록](../../docs/sw/sw-header-composition-shape-verification-2026-10-16.md).
+6 -5
View File
@@ -4,15 +4,16 @@
| 문서 | 내용 |
|---|---|
| [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트·Props·Callback·크기·CSS 의존성 |
| [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형 |
| [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header 조합 방법 |
| [examples/](./examples) | 공개 API만 쓰는 예제 A~D(독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증) |
| [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트(Header·개별 컨트롤·Popup·도구 막대)·Props·Callback·크기·CSS 의존성 |
| [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형·Popup 외형·도구 버튼 |
| [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header, Popup(자유 콘텐츠), 파일 열기 + 도구 막대 |
| [examples/](./examples) | 공개 API만 쓰는 예제 A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증) |
## 1. 작업 순서 (필수)
1. **먼저 검색한다.** 요청 기능을 [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md)의 공개 컴포넌트에서 찾는다.
2. **있으면 재사용한다(1순위).** 완성형 Header가 필요하면 `SWHeader` 하나만 쓴다. 기존 Props(`hiddenControls`, `menuOrder`, `controls`, 각 데이터 배열·Callback)로 해결되는지 먼저 본다.
2. **있으면 재사용한다(1순위).** 완성형 Header가 필요하면 `SWHeader` 하나만 쓴다. 기존 Props(`hiddenControls`, `menuOrder`, `controls`, `documentOpenPlacement`, `leading`, `tools`, 각 데이터 배열·Callback)로 해결되는지 먼저 본다. 팝업·설정창은 `SWPopup`, 버튼·탭·토글은 `SWToolButton` / `SWToolGroup`이다.
- Popup 요청은 이렇게 판단한다: ① `SWPopup`이 프레임(외형·닫기·Escape·스크롤)을 제공한다 → ② 내용은 `children`으로 자유 구성(구조 제한 없음) → ③ 행 모양은 `SWPopupSection` / `SWPopupRow` + `SWHeaderInput` → ④ SW에 없는 컨트롤은 `@hm-design/ui`를 children에 → ⑤ 프레임을 다시 만들거나 없는 Props(`content`, `renderContent` 등)를 추측하지 않는다.
3. **없으면 조합한다(2순위).** 개별 공개 컴포넌트(`SWHeaderHome`, `SWHeaderDocumentTitle`, `SWHeaderLayerMenu` …)를 `<SWTheme>` 안에 배치한다. 배치용 CSS만 소비 프로젝트에 작성한다([COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md)).
4. **조합으로도 안 될 때만 새로 만든다(3순위).** 새 컴포넌트는 **소비 프로젝트**에 작성하고, 공개 컴포넌트(예: `SWHeaderInput`)를 내부에 재사용하며, 외형은 공개 토큰 `--hm-sw-header-*`만 읽는다([DESIGN_RULES.md](./DESIGN_RULES.md), 예제 D).
5. **새 디자인 값을 만들지 않는다.** 크기·색·간격은 토큰과 DESIGN_RULES의 확인값만 쓴다. 값이 없으면 추측하지 말고 사용자에게 확인한다.
+46 -15
View File
@@ -1,12 +1,13 @@
# @hm-design/sw 컴포넌트 카탈로그
기준: `@hm-design/sw` 0.1.0, `dist/index.d.ts` 공개 Export(2026-10-19). 모든 Import는 `from "@hm-design/sw"`이며 CSS는 `@hm-design/sw/styles.css` 한 개입니다. 이 표에 없는 이름은 공개 API가 아닙니다.
기준: `@hm-design/sw` 0.2.0, `dist/index.d.ts` 공개 Export(2026-10-19 통합 개선 반영). 출처 Commit·빌드 도구 버전은 `dist/build-info.json`에 있습니다. 모든 Import는 `from "@hm-design/sw"`이며 CSS는 `@hm-design/sw/styles.css` 한 개입니다. 이 표에 없는 이름은 공개 API가 아닙니다.
- **단독 사용:** `SWHeader` 밖에서는 `<SWTheme>` 안에 둡니다(토큰·글꼴·버튼 초기화).
- **상태 소유:** 값은 Props, 사용자 행동은 Callback입니다. 컴포넌트는 CAD 데이터를 바꾸지 않습니다. 호스트가 Callback을 받아 Props를 갱신합니다.
- **열림 상태:** Dropdown은 `open` / `onOpenChange`(Controlled)를 주지 않으면 내부 상태로 동작합니다.
- **공통 키보드:** Click·Enter·Space 열기/닫기(Hover로는 열리지 않음), ArrowDown 열고 첫(현재) 항목 Focus, Arrow 이동, Escape·바깥 클릭·Tab 이탈 닫기(Escape는 Trigger로 Focus 복귀).
- **검증 표시:** ✅ = 2026-10-19 독립 소비 프로젝트(Tarball 설치)에서 브라우저 검증, ◻ = 이번 소비 검증 범위 밖(기존 Storybook 검증 기록만 있음).
- **검증 표시:** ✅ = 2026-10-19 독립 소비 프로젝트(Tarball 설치)에서 브라우저 검증, ◻ = 소비 검증 범위 밖(Storybook 검증 기록만 있음).
- **무엇을 쓸까:** Header 전체 → `SWHeader`(1). Header 부품 조합 → 개별 컨트롤(2). 팝업·설정창·정보창 → `SWPopup`(3). 도구 막대·탭·토글 → `SWToolButton` / `SWToolGroup`(4). Slider·Switch·Badge·Progress·Dialog(모달)·Label·FilterBar는 SW에 없습니다 → `@hm-design/ui`를 그대로 씁니다(필요하면 `SWPopup` children 안에).
## 1. 완성형 템플릿
@@ -16,7 +17,8 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
| 구분 | Props / Callback |
|---|---|
| 필수 | `documentTitle: string` |
| 구성 | `hiddenControls?: SWHeaderHideableControlId[]`(로고 외 부품 제거, `home` 제거 시 정적 로고), `menuOrder?: SWHeaderMenuId[]`(5개 메뉴끼리 순서), `controls?: SWHeaderControlConfig[]`(객체 영역 전체 allowlist·순서·label) |
| 구성 | `hiddenControls?: SWHeaderHideableControlId[]`(로고 외 부품 제거, `home` 제거 시 정적 로고. 하위 항목 `documentNew`·`documentOpen`·`layerFreeze`·`layerLock`도 제거 — 레이아웃 변화 없음), `menuOrder?: SWHeaderMenuId[]`(5개 메뉴끼리 순서), `controls?: SWHeaderControlConfig[]`(객체 영역 전체 allowlist·순서·label) |
| 로고 오른쪽 | `documentOpenPlacement?: "menu" \| "header"`(기본 `menu` = 기존 Dropdown 하단. `header` = 로고 바로 오른쪽에 파일명과 같은 면의 Open 버튼, Dropdown의 Open은 숨김. 둘 다 `onDocumentOpen`·`documentOpenLabel`), `leading?: ReactNode`(Open 뒤·파일명 앞 추가 부품, 예: `SWToolButton appearance="document"`) |
| 로고 | `logo?: ReactNode`, `logoSrc?: string`, `logoAlt?: string`, `onHomeClick` |
| 파일 | `documents`, `activeDocumentId`, `documentMenuOpen`, `onDocumentMenuOpenChange`, `onDocumentSelect`, `onDocumentClose`, `onDocumentNew`, `onDocumentOpen`, `onDocumentClick`, `documentIcon`, `documentNewLabel`, `documentOpenLabel` |
| 레이어 | `layer`, `layers`, `activeLayerId`, `layerMenuOpen`, `onLayerMenuOpenChange`, `onLayerSelect`, `onLayerVisibilityChange`, `onLayerFreezeChange`, `onLayerLockChange`, `onLayerDetail`, `onLayerOpen` |
@@ -28,7 +30,8 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
| 기타 | `activeMenu`, `disabled`(창 버튼 제외), `drawingModeLabel`, `tools?: ReactNode`(객체 영역 메뉴 뒤), `<header>` HTML 속성·`ref` |
- 데이터 배열(`layers`, `colors`, `lineTypes`/`lineWeights`, `textStyles`, `dimensionStyles`, `documents`)을 주지 않으면 해당 필드는 Dropdown 없는 버튼입니다.
- 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`).
- 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`). 조합형 구성이 반응형이어야 하면 `controls` + `documentOpenPlacement` + `leading` + `tools`로 `SWHeader` 안에서 구성합니다(예제 F).
- 로고 옆 Open은 실제 공간을 측정해 넘칠 때만 More로 이동하고, 그 시점보다 Open 폭만큼 넓어지면 돌아옵니다. Open이 보이는 동안 파일명이 먼저 줄고 레이어 필드는 Figma 폭을 유지합니다.
```tsx
<SWHeader documentTitle={name} logoSrc={logo} logoAlt="제품명" layers={layers} activeLayerId={id} onLayerSelect={l => setId(l.id)} onClose={closeWindow} />
@@ -40,36 +43,64 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
|---|---|---|---|---|---|
| `SWTheme` | 단독 사용 범위(토큰·글꼴·초기화). 배치는 정하지 않음 | `div` HTML 속성, `className`, `style`(토큰 override) | — | — | ✅ |
| `SWHeaderHome` | 로고 슬롯 + 홈 버튼 | `logo`, `logoSrc`, `logoAlt`, `onClick`, `interactive`(false = 정적 로고) | 80 × 48, 로고 슬롯 32 × 17, 오른쪽 −17px로 다음 파일 면과 겹침 | Hover 배경 교체, Focus | ✅ |
| `SWHeaderDocumentTitle` | 파일명 + 문서 Dropdown(+ New / Open) | `title`(필수), `documents`, `activeDocumentId`, `menuOpen`, `onMenuOpenChange`, `onDocumentSelect`, `onDocumentClose`, `onNew`, `onOpen`, `onClick`, `documentIcon`, `newLabel`, `openLabel`, `label` | 높이 48, 폭 190–310(반응형 clamp), 메뉴 310 | 텍스트만 Hover #FFC600, 열림 화살표 회전 | ✅ |
| `SWHeaderLayerMenu` | 레이어 필드 + Dropdown | `value`(필수), `layers`(필수), `activeLayerId`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onLayerSelect`, `onVisibilityChange`, `onFreezeChange`, `onLockChange`, `onDetail`, `label` | 필드 234 × 38, 메뉴 234 | Hover 링, Selected, Expanded, 행 Hover | ✅ |
| `SWHeaderDocumentTitle` | 파일명 + 문서 Dropdown(+ New / Open) | `title`(필수), `documents`, `activeDocumentId`, `menuOpen`, `onMenuOpenChange`, `onDocumentSelect`, `onDocumentClose`, `onNew`, `onOpen`, `onClick`, `documentIcon`, `newLabel`, `openLabel`, `label`, `showNew` / `showOpen`(기본 true, 둘 다 false면 하단 없음) | 높이 48, 폭 190–310(반응형 clamp), 메뉴 310 | 텍스트만 Hover #FFC600, 열림 화살표 회전 | ✅ |
| `SWHeaderLayerMenu` | 레이어 필드 + Dropdown | `value`(필수), `layers`(필수), `activeLayerId`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onLayerSelect`, `onVisibilityChange`, `onFreezeChange`, `onLockChange`, `onDetail`, `label`, `showFreeze` / `showLock`(기본 true. false = 그 상태를 그리지 않고 20px 자리만 유지) | 필드 234 × 38, 메뉴 234 | Hover 링, Selected, Expanded, 행 Hover | ✅ |
| `SWHeaderColorMenu` | 색상 필드 + Dropdown(ByLayer/ByBlock, 팔레트, 사용자 색, 추출, RGB 입력) | `value`, `colors`(필수), `userColors`, `mode`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onColorSelect`, `onModeChange`, `onColorInput`, `onPick`, `onDetail`, `label` | 필드 192 × 38 | 칩 선택 시 열림 유지 | ✅ |
| `SWHeaderLineStyleMenu` | 선스타일 필드 + 3열 Dropdown | `value`, `lineTypes`, `lineWeights`, `activeLineTypeId`, `activeLineWeightId`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onLineTypeSelect`, `onLineWeightSelect`, `onLineWeightValueChange`, `onScaleChange`, `onDetail`, `label` | 필드 284 × 38, 열 182·142·142(필드 중앙 정렬) | 열별 선택, 열림 유지 | ✅ |
| `SWHeaderTextStyleMenu` | 문자스타일 필드 + 목록 | `value`, `textStyles`(필수), `activeTextStyleId`, `onTextStyleSelect`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onDetail`, `label` | 필드 164 × 38 | 선택 시 닫힘 | ✅ |
| `SWHeaderDimensionStyleMenu` | 치수스타일 필드 + 목록 | `value`, `dimensionStyles`(필수), `activeDimensionStyleId`, `onDimensionStyleSelect`, 열림·`onDetail`·`label` 동일 | 필드 164 × 38 | 선택 시 닫힘 | ✅ |
| `SWHeaderLayerControl` · `SWHeaderColorControl` · `SWHeaderLineStyle` · `SWHeaderTextStyle` · `SWHeaderDimensionStyle` | Dropdown 없는 필드 버튼(호스트가 자체 팝업을 열 때) | `value`(필수), `label`, `selected`, `disabled`, `onClick`, `expanded`, `controls`, `buttonRef` / `SWHeaderLayerControl`은 `state`(표시·동결·잠금) | 위 필드 크기와 같음 | Hover 링, Selected | ◻ |
| `SWHeaderLayerControl` · `SWHeaderColorControl` · `SWHeaderLineStyle` · `SWHeaderTextStyle` · `SWHeaderDimensionStyle` | Dropdown 없는 필드 버튼(호스트가 자체 팝업을 열 때) | `value`(필수), `label`, `selected`, `disabled`, `onClick`, `expanded`, `controls`, `buttonRef` / `SWHeaderLayerControl`은 `state`(표시·동결·잠금), `showFreeze` / `showLock` | 위 필드 크기와 같음 | Hover 링, Selected | ◻ |
| `SWHeaderActions` | 인쇄·K 버튼 pill | `onPrint`, `onPopupGuide`, `disabled`, `showPrint`, `showPopupGuide`(하나만 남기면 pill이 축소) | pill 85 × 42(버튼 40 × 38) | 아이콘 Hover, Hover 링, K 가이드 미리보기(반복 Motion, Reduced Motion 정적) | ✅ |
| `SWHeaderWindowControls` | 창 이동·최소화·종료 | `onWindowMoveStart`, `onMinimize`, `onClose`, `showWindowMove`, `showMinimize`, `showClose` | 이동 88 × 48, 버튼 32 × 48 | 창 이동 Hover Motion 1.4s, 종료 Hover 자산 | ◻ |
| `SWHeaderProperties` | 상세보기 / 속성Bar · 속성창 토글 | `barOpen`, `panelOpen`, `panelId`, `disabled`, `onBarToggle`, `onPanelToggle`, `showCaption`, `showBar`, `showPanel` | 157 × 38 | 텍스트 Hover #FFC600 | ◻ |
| `SWHeaderPropertyBar` | 28px 속성 편집 띠 | `selection`, `properties`, `onPropertyChange`, `readOnly`, `disabled`, `div` 속성 | 높이 28, 콘텐츠 폭 | Input 상태 | ◻ |
| `SWHeaderPropertyPanel` | 속성창(13개 필드 타입, 그룹, 형상 Slot) | `selection`, `properties`, `groups`, `onPropertyChange`, `menus`, `expandedGroupIds`/`defaultExpandedGroupIds`/`onExpandedGroupsChange`, `toolActive`/`onToolActiveChange`, `toolLabel`, `onOverviewClick`, `shapeContent`, `onClose`, `id`, `anchorRef`, `style` | 고정 위치 Overlay(Portal·Dim 없음) | 4가지 선택 상태 | ◻ |
| `SWHeaderPropertyPanel` | 데이터 기반 속성창(필드 타입 6종 `text`·`number`·`select`·`color`·`slider`·`computed` + picker, 그룹 레이아웃 `rows`·`circle`·`position`, 형상 Slot) | `selection`·`properties`·`groups`(0.2.0부터 선택), `title`, `children`(그룹 뒤 자유 콘텐츠), `autoFocus`, `onPropertyChange`, `menus`(`showLayerFreeze`/`showLayerLock` 포함), `expandedGroupIds`/`defaultExpandedGroupIds`/`onExpandedGroupsChange`, `toolActive`/`onToolActiveChange`, `toolLabel`, `onOverviewClick`, `shapeContent`, `onClose`, `id`, `anchorRef`, `style` | 314 폭, 높이는 콘텐츠(최대 화면, 본문 스크롤). `anchorRef`가 있으면 고정 위치 Overlay, 없으면 문서 흐름(위치는 `style`) | 4가지 선택 상태 | ✅ |
| `SWHeaderInput` | 수정 가능한 SW Input | `value`(필수), `onValueChange`, `onCommit`(Enter·Blur, 유효 + 변경 시), `onCancel`(Escape 복원), `valueType`(`text`/`number`), `parse`, `readOnly`, `disabled`, `commitOnUnmount`, `input` 속성 | 58 × 20 기준, Hover/편집 24px | Default/Hover/편집/ReadOnly/Disabled, IME 조합 중 Enter 무시 | ✅ |
| `parseSWHeaderColor` · `parseSWHeaderNumber` | Input 검증 도우미 | `r,g,b` / `#rrggbb`, 숫자(`min`, `max`) | — | — | ◻ |
| `SWHeaderLinePreview` · `swHeaderLinePatterns` | 70 × 2 선 미리보기, CENTER/HIDDEN 패턴 | `pattern?: number[]` | 70 × 2 | — | ◻(패턴 상수만 예제 데이터에서 사용) |
## 3. 공개 타입
## 3. Popup — 외형 고정, 콘텐츠 자유
`SWHeaderProps`, 각 컴포넌트 `*Props`, `SWThemeProps`, `SWHeaderMenuId`, `SWHeaderControlId`, `SWHeaderControlConfig`, `SWHeaderFrameControlId`, `SWHeaderHideableControlId`, `SWHeaderDocument`, `SWHeaderLayer`, `SWHeaderLayerValue`, `SWHeaderColorValue`, `SWHeaderColorOption`, `SWHeaderColorMode`, `SWHeaderColorInputValue`, `SWHeaderLineStyleValue`, `SWHeaderLineType`, `SWHeaderLineWeight`, `SWHeaderTextStyleValue`, `SWHeaderTextStyleOption`, `SWHeaderDimensionStyleValue`, `SWHeaderDimensionStyleOption`, `SWHeaderInputValueType`, `SWHeaderProperty`, `SWHeaderPropertyType`, `SWHeaderPropertyPicker`, `SWHeaderPropertyMenus`, `SWHeaderPropertyGroup`, `SWHeaderPropertySelection`, `SWHeaderPropertyShapeContent`, `SWHeaderPropertyShapeContext`.
속성창(Figma 26:11822)의 외형과 공통 동작을 그대로 쓰는 Popup입니다. Header 없이 `<SWTheme>` 안에서 씁니다. 내용은 `children`으로 자유롭게 구성합니다(SW 컨트롤, `@hm-design/ui` 컴포넌트, 소비 프로젝트 요소 모두 가능, 구조 제한 없음).
## 4. 공개하지 않은 것 (사용 금지)
| 컴포넌트 | 용도 | 주요 Props | 크기·동작 | 검증 |
|---|---|---|---|---|
| `SWPopup` | 설정·정보·사용자 정의 Popup 프레임 | `title`(필수), `children`, `onClose`(닫기 버튼·Escape), `closeLabel`(기본 `닫기`), `autoFocus`(열릴 때 Focus, 안에서 닫히면 원래 위치로 복귀), `anchorRef`(기준 요소 아래 고정 Overlay), `style`·`className`·`div` 속성 | 폭 314, 제목줄 30, 높이는 콘텐츠(최대 화면 − 8px, `style.maxHeight`로 더 줄일 수 있음) → 본문 스크롤. Non-modal(Dim·Portal·Focus trap 없음) | ✅ |
| `SWPopupSection` | 속성창 그룹 면(제목 + 내용) | `title`, `children`, `section` 속성 | 그룹 제목 700·70% 검정, 내용 면 `--hm-sw-property-content` | ✅ |
| `SWPopupRow` | 속성창 한 줄(라벨 + 값) | `label`(필수), `htmlFor`, `children`(값 컨트롤, SW 필드는 `SWHeaderInput`), `unit`, `field`(`box` 기본 · `compact` · `none`), `numeric`(숫자 편집 On), `readOnly`, `disabled` | 행 최소 32, 라벨 72, 값 상자 28(Default·Hover 테두리, 숫자 편집 시 #0F0D0A). `compact` = 41px hug 숫자 필드 | ✅ |
| `SWHeaderPropertyPanel` | 데이터 기반 속성창(2절). 같은 프레임이며 `children`도 받음 | 2절 | 같은 DOM·class | ✅ |
`SWHeaderMore`, `SWHeaderField`/`SWHeaderFieldText`, `SWHeaderKGuide`, `useSWHeaderDropdown`, `SWHeaderMenuDetail`, `SWHeaderColorChip`, `SWHeaderLayerRows`, `SWHeaderColorPanelBody`, `SWHeaderLineStyleColumns`, 속성창 내부 Picker, `useSWHeaderOverlay` 등은 내부 구현입니다. 필요하면 공개 컴포넌트로 대체하거나, 부족한 API로 보고합니다.
- **Escape·Focus:** Popup 안의 Escape는 그 Popup만 닫습니다. 바깥의 Escape는 가장 최근에 열리거나 사용한(Focus·클릭) Popup 하나만 닫습니다. Dropdown·편집 중인 Input처럼 Escape를 이미 처리한 요소(`preventDefault` / 전파 중단)가 있으면 Popup은 닫히지 않습니다.
- **Popup 안 SW Dropdown:** 레이어·색상·선·문자·치수 Dropdown은 Popup 안에서 고정 위치 Overlay로 열려 스크롤 본문에 잘리지 않습니다(아래에 자리가 없으면 위로 열림). 단, Header용 어두운 필드 모양 그대로이므로 밝은 Popup 면과 대비가 다릅니다. 속성창 행 모양의 선택 필드가 필요하면 `SWHeaderPropertyPanel`의 `picker` 속성(`properties`)을 씁니다.
- **Popup 하단 버튼(적용·취소)의 SW 전용 디자인은 아직 없습니다.** 필요하면 소비 프로젝트 버튼이나 `@hm-design/ui`의 `Button`을 `children`에 둡니다. 어두운 `SWToolButton`을 밝은 Popup에 강제로 쓰지 않습니다.
## 5. 알려진 단독 사용 제약
## 4. 도구 막대 — 기존 SW 버튼 3종
새 디자인 값 없이 기존 SW 버튼 모양을 공개 버튼으로 씁니다(뷰어의 Model/Layout, Fit, 2.5D, 파일 열기 등). `SWHeader`의 `tools`·`leading` 안이나 `<SWTheme>` 안에 둡니다.
| 컴포넌트 | 용도 | 주요 Props | 모양(기존 기준) | 검증 |
|---|---|---|---|---|
| `SWToolButton` | 버튼·토글 | `appearance`(`text` 기본 / `pill` / `document`), `pressed`(토글·선택), `children`(글자. `pill`은 접근성 이름으로만 사용), `icon`·`hoverIcon`(`pill`), `button` 속성 | `text` = 속성Bar·속성창 글자 토글(700, Hover·선택 #FFC600, 행 19px). `pill` = 인쇄·K 40 × 38(노란 Hover 링, 아이콘 24 × 23 영역). `document` = 파일명 면(48px, 글자만 Hover #FFC600). Home 바로 뒤에 두면 로고 사선 접점을 그대로 공유 | ✅ |
| `SWToolGroup` | 버튼 묶음 | `appearance`(`text` 기본 = 상세보기 묶음, `pill` = 인쇄·K pill), `label`(`text`의 12px 캡션·접근성 이름), `selection`(`single` = radiogroup, 화살표·Home·End로 이동·선택), `div` 속성 | 버튼 사이 1px 구분선(`text`), 내용 폭 pill | ✅ |
- 탭(Model / Layout)은 별도 컴포넌트 없이 `SWToolGroup selection="single"` + `SWToolButton pressed`입니다.
- 상태 표시(로드 상태 등) 전용 SW 디자인은 아직 없습니다(상태 표시 컴포넌트는 디자인 확정 후 검토).
## 5. 공개 타입
`SWHeaderProps`, 각 컴포넌트 `*Props`, `SWThemeProps`, `SWPopupProps`, `SWPopupSectionProps`, `SWPopupRowProps`, `SWToolButtonProps`, `SWToolButtonAppearance`, `SWToolGroupProps`, `SWHeaderMenuId`, `SWHeaderControlId`, `SWHeaderControlConfig`, `SWHeaderFrameControlId`, `SWHeaderSubControlId`, `SWHeaderHideableControlId`, `SWHeaderDocument`, `SWHeaderLayer`, `SWHeaderLayerValue`, `SWHeaderColorValue`, `SWHeaderColorOption`, `SWHeaderColorMode`, `SWHeaderColorInputValue`, `SWHeaderLineStyleValue`, `SWHeaderLineType`, `SWHeaderLineWeight`, `SWHeaderTextStyleValue`, `SWHeaderTextStyleOption`, `SWHeaderDimensionStyleValue`, `SWHeaderDimensionStyleOption`, `SWHeaderInputValueType`, `SWHeaderProperty`, `SWHeaderPropertyType`, `SWHeaderPropertyPicker`, `SWHeaderPropertyMenus`, `SWHeaderPropertyGroup`, `SWHeaderPropertySelection`, `SWHeaderPropertyShapeContent`, `SWHeaderPropertyShapeContext`.
## 6. 공개하지 않은 것 (사용 금지)
`SWHeaderMore`, `SWHeaderField`/`SWHeaderFieldText`, `SWHeaderKGuide`, `useSWHeaderDropdown`, `SWHeaderMenuDetail`, `SWHeaderColorChip`, `SWHeaderLayerRows`, `SWHeaderColorPanelBody`, `SWHeaderLineStyleColumns`, `SWPopupFrame`, `useSWFixedDropdownPosition`, 속성창 내부 Picker, `useSWHeaderOverlay` 등은 내부 구현입니다. 필요하면 공개 컴포넌트로 대체하거나, 부족한 API로 보고합니다.
## 7. 알려진 단독 사용 제약
| 항목 | 내용 | 대응 |
|---|---|---|
| 반응형 More | `SWHeader` 전용. 조합 Header에서는 More·필드 이동이 없음 | 조합 Header의 폭은 소비 앱이 정함(필요 없는 컨트롤을 빼거나 가로 공간 확보) |
| 반응형 More | `SWHeader` 전용. `SWTheme`로 직접 배치한 조합 Header에는 More·필드 이동이 없음 | 반응형이 필요하면 `SWHeader`의 `controls`·`documentOpenPlacement`·`leading`·`tools`로 구성(예제 F). 직접 배치하면 폭은 소비 앱이 정함 |
| `SWHeaderDocumentTitle` 왼쪽 테두리 | 앞의 로고와 겹치는 전제로 왼쪽 테두리가 없음 | 로고 없이 첫 칸이면 소비 레이아웃 wrapper에 1px 테두리(예제 C) |
| `SWHeaderDocumentTitle` 폭 | `clamp(190px, 100cqw − 1610px, 310px)`; Header 밖에서는 cqw가 뷰포트 기준 | flex 부모 안에 둔다. 1920 뷰포트에서 310px |
| 창 영역 배경 | 배경·테두리는 `SWHeader`의 창 영역에 있음 | 소비 레이아웃이 토큰으로 표면 작성(예제 B·C) |
| Dropdown 위치 | `SWHeader` 안이면 헤더, 밖이면 뷰포트 안으로 좌우 보정 | 부모에 `overflow: hidden`을 두지 않는다 |
| Dropdown 위치 | `SWHeader` 안이면 헤더, 밖이면 뷰포트 안으로 좌우 보정. `SWPopup`·속성창 안이면 고정 위치 Overlay | Popup 밖에서는 부모에 `overflow: hidden`을 두지 않는다 |
| 파일 열기 뒤 파일명 여백 | `SWToolButton appearance="document"` 바로 뒤의 파일명은 왼쪽 여백 12px(로고 아래가 아니므로) | 패키지 CSS가 처리. 소비 코드에서 조정하지 않음 |
+44 -1
View File
@@ -10,6 +10,8 @@
| 기존 컨트롤 일부만, 다른 배치·순서 | 2. 조합 Header |
| 기존 Dropdown을 툴바·패널 등 다른 곳에 | 3. 개별 컨트롤 |
| 패키지에 없는 기능 | 4. 확장(소비 프로젝트 신규 컴포넌트) |
| SW Popup 디자인의 설정창·정보창·사용자 정의 폼 | 5. Popup(외형 고정, 콘텐츠 자유) |
| 로고 옆 파일 열기, 뷰어 도구 막대(Model/Layout, Fit, 2.5D) | 6. 파일 열기 + 도구 막대 |
## 1. 완성형 SW Header — [ExampleA-DefaultHeader.tsx](./examples/ExampleA-DefaultHeader.tsx)
@@ -73,7 +75,48 @@
새 컨트롤은 패키지 공식 컴포넌트로 자동 등록하지 않습니다. 여러 프로젝트에서 반복되면 SW 패키지 승격을 별도로 검토합니다.
## 5. 하지 말 것
## 5. Popup — 외형 고정, 콘텐츠 자유 — [ExampleE-CustomPopup.tsx](./examples/ExampleE-CustomPopup.tsx)
요청 예: "HMDesign SW Popup 디자인을 사용해서 내부에 새로운 설정 폼을 만들어 줘."
1. 카탈로그 3절에서 `SWPopup`을 찾는다(프레임 = 속성창 외형·닫기·Escape·스크롤).
2. 내용은 `children`으로 구성한다. 행 모양은 `SWPopupSection` + `SWPopupRow`, 값 입력은 `SWHeaderInput`.
3. SW에 없는 컨트롤(Switch, Slider, Select, Button 등)은 `@hm-design/ui`를 `children` 안에 그대로 둔다. 구조 제한은 없다.
4. 프레임(폭, 제목줄, 닫기, 표면색)을 다시 만들지 않고, `hm-sw-property*` class를 쓰지 않는다.
```tsx
<SWTheme>
<SWPopup title="도면 설정" onClose={close}>
<SWPopupSection title="도면">
<SWPopupRow label="이름" htmlFor={id}><SWHeaderInput id={id} value={name} onCommit={setName} /></SWPopupRow>
<SWPopupRow label="축척" unit="%" numeric><SWHeaderInput aria-label="축척" valueType="number" value={scale} onCommit={setScale} /></SWPopupRow>
</SWPopupSection>
<MyProjectFields />
</SWPopup>
</SWTheme>
```
- 데이터 기반 속성창이 필요하면 `SWHeaderPropertyPanel`(`properties` / `groups`)을 쓰고, 추가 내용은 같은 컴포넌트의 `children`으로 붙인다.
- 위치는 호스트가 정한다: 문서 흐름(`style`) 또는 `anchorRef`(기준 요소 아래 고정 Overlay). 열고 닫는 상태도 호스트가 가진다(조건부 렌더링).
- 여러 Popup을 동시에 띄워도 Escape는 하나씩 닫힌다(카탈로그 3절).
## 6. 파일 열기 + 도구 막대 — [ExampleF-ViewerToolbar.tsx](./examples/ExampleF-ViewerToolbar.tsx)
```tsx
<SWHeader documentTitle={name} documents={documents} documentOpenPlacement="header" documentOpenLabel="파일 열기" onDocumentOpen={open}
controls={[{ id: "document" }, { id: "layer" }, { id: "color" }, { id: "popupGuide" }]}
hiddenControls={["documentNew", "layerFreeze", "layerLock"]}
tools={<SWToolGroup label="보기" selection="single">
<SWToolButton pressed={view === "model"} onClick={showModel}>Model</SWToolButton>
<SWToolButton pressed={view === "layout"} onClick={showLayout}>Layout</SWToolButton>
</SWToolGroup>} />
```
- 파일 열기는 `tools`에 직접 만들지 않는다. `documentOpenPlacement="header"`가 로고 바로 오른쪽에 파일명과 같은 면으로 그리고 Dropdown의 Open은 숨긴다(중복 없음). 공간이 부족하면 More로 이동한다.
- 지원하지 않는 기능은 `hiddenControls`로 뺀다(`documentNew`, `documentOpen`, `layerFreeze`, `layerLock`). 가짜 버튼을 두지 않는다.
- `SWHeader` 없이 같은 순서가 필요하면: `SWHeaderHome` → `SWToolButton appearance="document"` → `SWHeaderDocumentTitle showOpen={false}`(사선 접점·12px 여백은 패키지가 처리). 이 경우 More는 없다.
## 7. 하지 말 것
- `hm-sw-*` class를 소비 코드에 쓰거나 CSS로 덮어쓰기
- `dist` 구현·`styles.css` 블록 복사로 같은 컴포넌트 재작성
+29 -1
View File
@@ -84,7 +84,33 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
조합 Header(개별 컴포넌트)에는 이 규칙이 적용되지 않습니다. 필요한 컨트롤만 배치하고 폭 정책은 소비 앱이 정합니다.
## 8. 새 컴포넌트 체크리스트
## 8. Popup (속성창 프레임, `SWPopup` / `SWHeaderPropertyPanel`)
프로젝트마다 내용이 달라도 아래 외형은 그대로 유지됩니다(프레임은 패키지가 그림). 새 Popup 화면은 `SWPopup`을 쓰고 프레임을 다시 만들지 않습니다.
| 항목 | 값 | 토큰 |
|---|---|---|
| 폭 / 높이 | 314px / 콘텐츠 높이(최대 화면 − 8px), 넘치면 본문 세로 스크롤 | — |
| 표면 / 테두리 / radius | `#E4DBC9` / 1px `#A67D03` / 4px, 안쪽 1px 검정 그림자 | `--hm-sw-property-surface`, `--hm-sw-property-border` |
| 제목줄 | 30px, Figma 제목 아트, 제목 14px 700 `#FFC600` + 검정 외곽선, 닫기 44 × 30(Hover 자산) | `--hm-sw-header-selected-color` |
| 본문 | padding 7px, 그룹 사이 8px | — |
| 그룹 면(`SWPopupSection`) | 제목 700 · 검정 70%, 내용 면 `#F1ECE1`, 1px `#585349`, radius 4px | `--hm-sw-property-content` |
| 행(`SWPopupRow`) | 최소 32px, 라벨 72px 700 검정, 값 글자 `#E60403` 500, 단위 12px | `--hm-sw-property-label`, `--hm-sw-header-input-color` |
| 값 상자 | 28px, Hover 1px `rgba(33,29,19,.4)`, 숫자 편집 On `#0F0D0A`(단위 흰색), Dropdown On `#211D13` | `--hm-sw-property-field-*` |
| 동작 | Non-modal(Dim·Portal·Focus trap 없음). Escape는 Popup 안이면 그 Popup, 밖이면 최근 사용 Popup 하나. `autoFocus`면 열릴 때 Focus, 안에서 닫히면 원래 위치로 | — |
- 밝은 Popup 면에는 SW Header의 어두운 컨트롤(필드·`SWToolButton`)이 디자인상 맞지 않습니다. 쓸 수는 있지만(Dropdown은 고정 Overlay로 잘리지 않음) 행 모양이 필요하면 `SWPopupRow` + `SWHeaderInput` 또는 속성창 `picker`를 씁니다.
- Popup 하단 버튼의 SW 디자인은 없습니다. 임의 색·크기로 만들지 말고 `@hm-design/ui` `Button` 등 기존 컴포넌트를 쓰거나 사용자에게 디자인을 확인합니다.
## 9. 도구 버튼 (`SWToolButton` / `SWToolGroup`)
| appearance | 기존 기준 | 상태 |
|---|---|---|
| `text` | 속성Bar·속성창 토글: 19px 행, 14px 700, 자간 0.04em, 묶음 위 12px 캡션, 버튼 사이 1px 구분선 | Hover·`pressed` 글자 `#FFC600`, 테두리·배경 변화 없음 |
| `pill` | 인쇄·K: pill(`rgba(0,0,0,.8)`, 1px `#092920`, radius 4px) 안 40 × 38 버튼, 아이콘 영역 24 × 23 | Hover 노란 gradient 링 + `hoverIcon`. 아이콘 색은 기존 인쇄 아이콘 `#B6D0C9` → Hover `#FFC600`(`--hm-sw-header-selected-color`) |
| `document` | 파일명 면: 48px, gradient 표면, 위·오른쪽·아래 1px `#0E4C3A`, 글자 `#F0F7F5` 700 · 자간 0.06em | 글자만 Hover `#FFC600`. Home 바로 뒤면 로고 사선 접점 공유, 그 뒤 파일명은 왼쪽 여백 12px |
## 10. 새 컴포넌트 체크리스트
1. `<SWTheme>`(또는 `SWHeader`의 `tools`) 안에서 렌더링한다.
2. 표면·크기·색은 위 토큰만 쓴다. 필드형이면 38px·속성 필드 면·12px 라벨 + 14px 700 값.
@@ -92,3 +118,5 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
4. 입력이 필요하면 `SWHeaderInput`을 재사용한다.
5. 값은 Props, 동작은 Callback. CAD 데이터를 내부에서 바꾸지 않는다.
6. `hm-sw-*` class를 새로 쓰거나 덮어쓰지 않는다(자기 접두사 사용, 예: `app-`).
7. 버튼·탭·토글이면 먼저 `SWToolButton`의 세 모양(§9)으로 해결되는지 본다. 팝업·설정창이면 `SWPopup` + `SWPopupSection` / `SWPopupRow`(§8).
8. 팝업 안의 새 콘텐츠는 §8의 Popup 토큰(`--hm-sw-property-*`)만 읽는다. Header용 어두운 토큰을 밝은 Popup 면에 섞지 않는다.
@@ -0,0 +1,40 @@
// Example E — the SW popup frame with project content (no Header). SWPopup keeps the property window design;
// children are free: SWPopupSection / SWPopupRow rows with SWHeaderInput, and an existing SW dropdown.
import { useId, useState } from "react"
import { SWHeaderLayerMenu, SWHeaderInput, SWPopup, SWPopupRow, SWPopupSection, SWTheme } from "@hm-design/sw"
import { layers } from "./sample-data"
export function ExampleECustomPopup({ onEvent }: { onEvent?: (name: string) => void }) {
const [open, setOpen] = useState(true)
const nameId = useId()
const [name, setName] = useState("평면도-01")
const [scale, setScale] = useState("100")
const [x, setX] = useState("1200.5")
const [activeLayerId, setActiveLayerId] = useState("0")
if (!open) return <button type="button" onClick={() => setOpen(true)}>설정 열기</button>
return (
<SWTheme>
<SWPopup title="도면 설정" onClose={() => { setOpen(false); onEvent?.("popup:close") }}>
<SWPopupSection title="도면">
<SWPopupRow label="이름" htmlFor={nameId}>
<SWHeaderInput id={nameId} value={name} onCommit={next => { setName(next); onEvent?.(`name:${next}`) }} />
</SWPopupRow>
<SWPopupRow label="축척" unit="%" numeric>
<SWHeaderInput aria-label="축척" valueType="number" value={scale} onCommit={next => { setScale(next); onEvent?.(`scale:${next}`) }} />
</SWPopupRow>
</SWPopupSection>
<SWPopupSection title="객체 정보">
<SWPopupRow label="타입" readOnly><SWHeaderInput aria-label="타입" value="Line" readOnly /></SWPopupRow>
<SWPopupRow label="X" unit="mm" numeric>
<SWHeaderInput aria-label="X" valueType="number" value={x} onCommit={next => { setX(next); onEvent?.(`x:${next}`) }} />
</SWPopupRow>
</SWPopupSection>
<SWPopupSection title="레이어">
{/* Inside a popup the dropdown opens as a fixed overlay, so the scrolling body never clips it. */}
<SWHeaderLayerMenu value={{ name: "0" }} layers={layers} activeLayerId={activeLayerId}
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }} />
</SWPopupSection>
</SWPopup>
</SWTheme>
)
}
@@ -0,0 +1,50 @@
// Example F — a viewer header: logo → Open → file → layer → color → K, plus a tool bar (Model / Layout, 2.5D,
// Fit) after the menus. Everything is SWHeader props, so the existing responsive More serves the whole layout
// (Open moves into More when the measured header has no room). No freeze / lock, no "+ New" in this product.
import { useState } from "react"
import { SWHeader, SWToolButton, SWToolGroup } from "@hm-design/sw"
import { colors, documents, layers } from "./sample-data"
// The program supplies its icons (24 x 23 box of a pill button). Colors: the existing print icon fill #B6D0C9,
// hover --hm-sw-header-selected-color (#FFC600), as in DESIGN_RULES §2.
const FitIcon = ({ color }: { color: string }) => (
<svg viewBox="0 0 24 23" width="24" height="23" aria-hidden="true">
<path d="M2 8V2h6M16 2h6v6M22 15v6h-6M8 21H2v-6" fill="none" stroke={color} strokeWidth="2" />
</svg>
)
export function ExampleFViewerToolbar({ onEvent }: { onEvent?: (name: string) => void }) {
const [activeLayerId, setActiveLayerId] = useState("0")
const [view, setView] = useState<"model" | "layout">("model")
const [depth, setDepth] = useState(false)
return (
<SWHeader
documentTitle={documents[0].name}
documents={documents}
activeDocumentId="doc-1"
documentOpenPlacement="header"
documentOpenLabel="파일 열기"
onDocumentOpen={() => onEvent?.("document:open")}
onDocumentSelect={document => onEvent?.(`document:${document.id}`)}
controls={[{ id: "document" }, { id: "layer" }, { id: "color" }, { id: "popupGuide" }]}
hiddenControls={["documentNew", "layerFreeze", "layerLock"]}
layers={layers}
activeLayerId={activeLayerId}
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
colors={colors}
onColorSelect={option => onEvent?.(`color:${option.id}`)}
tools={<>
<SWToolGroup label="보기" selection="single">
<SWToolButton pressed={view === "model"} onClick={() => { setView("model"); onEvent?.("view:model") }}>Model</SWToolButton>
<SWToolButton pressed={view === "layout"} onClick={() => { setView("layout"); onEvent?.("view:layout") }}>Layout</SWToolButton>
</SWToolGroup>
<SWToolGroup label="표시">
<SWToolButton pressed={depth} onClick={() => { setDepth(!depth); onEvent?.(`2.5d:${!depth}`) }}>2.5D</SWToolButton>
</SWToolGroup>
<SWToolGroup appearance="pill" aria-label="화면">
<SWToolButton appearance="pill" icon={<FitIcon color="#B6D0C9" />} hoverIcon={<FitIcon color="var(--hm-sw-header-selected-color)" />} onClick={() => onEvent?.("fit")}>화면 맞춤</SWToolButton>
</SWToolGroup>
</>}
/>
)
}
+30
View File
@@ -55,3 +55,33 @@ async function finishDeclarations(directory) {
}
}
await finishDeclarations(output)
// Reproducibility: the same source and the same locked tools give byte-identical dist on any path or
// checkout. Sources are copied as written (styles.css keeps sw.css bytes), so CRLF checkouts
// (core.autocrlf=true) would change the output; every emitted text file is normalized to LF.
async function normalizeLineEndings(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name)
if (entry.isDirectory()) await normalizeLineEndings(file)
else if (/\.(js|css|d\.ts|json)$/.test(entry.name)) {
const text = await readFile(file, "utf8")
if (text.includes("\r")) await writeFile(file, text.replace(/\r\n?/g, "\n"))
}
}
}
await normalizeLineEndings(output)
// Provenance without timestamps: source commit (SW_BUILD_COMMIT overrides; "unknown" outside git),
// whether packages/sw had uncommitted changes, and the locked build tool versions.
const git = args => { try { return execFileSync("git", args, { cwd: packageRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() } catch { return null } }
const toolVersion = name => { try { return JSON.parse(execFileSync(process.execPath, ["-p", `JSON.stringify(require("${name}/package.json").version)`], { cwd: packageRoot, encoding: "utf8" })) } catch { return null } }
const commit = process.env.SW_BUILD_COMMIT ?? git(["rev-parse", "HEAD"]) ?? "unknown"
// With SW_BUILD_COMMIT the caller vouches for the source; dirty is then unknown (null), never a guess.
const status = process.env.SW_BUILD_COMMIT ? null : git(["status", "--porcelain", "--", "."])
await writeFile(path.join(output, "build-info.json"), JSON.stringify({
name: manifest.name,
version: manifest.version,
commit,
dirty: status === null ? null : status.length > 0,
tools: { vite: toolVersion("vite"), rolldown: toolVersion("rolldown"), typescript: toolVersion("typescript") },
}, null, 2) + "\n")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hm-design/sw",
"version": "0.1.0",
"version": "0.2.0",
"private": false,
"description": "HM Design System SW (EasyBeam-style desktop software) components. Independent of @hm-design/ui.",
"type": "module",
@@ -19,7 +19,8 @@ import printHover from "../../assets/print-hover.svg"
import { SWHeaderPropertyPanel } from "./components/SWHeaderPropertyPanel"
import { SWHeaderDimensionStyleMenu, SWHeaderTextStyleMenu } from "./components/SWHeaderStyleListMenus"
import { SWHeaderWindowControls } from "./components/SWHeaderWindowControls"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps } from "./types"
import { SWToolButton } from "../sw-tool/SWTool"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
const defaults = {
layer: { name: "0" },
@@ -37,6 +38,7 @@ const menuSlot: Record<SWHeaderMenuId, string> = {
const defaultMenuOrder = Object.keys(menuSlot) as SWHeaderMenuId[]
const defaultControls: SWHeaderControlConfig[] = (["document", "drawingMode", ...defaultMenuOrder, "details", "propertyBar", "propertyPanel", "print", "popupGuide"] as const).map(id => ({ id }))
const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove", "minimize", "close"]
const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"]
const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot
/**
@@ -73,6 +75,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
documentIcon,
documentNewLabel,
documentOpenLabel,
documentOpenPlacement = "menu",
leading,
drawingModeLabel = "그리기모드",
layer = defaults.layer,
layers,
@@ -161,7 +165,9 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const enabled = (id: SWHeaderControlId) => configuration.some(item => item.id === id)
// Default design with removed object parts: same structure and breakpoints, but the script decides what
// moves into More (the CSS alone assumes every field is present). Untouched defaults stay CSS-only.
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId))
// Sub-items live inside a part and never change the layout mode.
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId) && !subControls.includes(id as SWHeaderSubControlId))
const layerStates = { showFreeze: shown("layerFreeze"), showLock: shown("layerLock") }
const presentRef = useRef(new Set(configuration.map(item => item.id)))
presentRef.current = new Set(configuration.map(item => item.id))
const label = (id: SWHeaderControlId, fallback: string) => configuration.find(item => item.id === id)?.label ?? fallback
@@ -204,6 +210,13 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const [headerWidth, setHeaderWidth] = useState(1920)
const [barOffset, setBarOffset] = useState(0)
// Open next to the logo (documentOpenPlacement="header") moves into More when the measured header has no room.
const openInHeader = documentOpenPlacement === "header" && shown("documentOpen")
const leadingRef = useRef<HTMLDivElement>(null)
const [openCollapsed, setOpenCollapsed] = useState(false)
const collapseRef = useRef<{ at: number; width: number } | null>(null)
const openShown = openInHeader && !openCollapsed
const hasLeading = openShown || (leading !== undefined && leading !== null && leading !== false)
useLayoutEffect(() => {
const header = fieldsRef.current?.closest(".hm-sw-header")
if (!header) return
@@ -218,6 +231,35 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
if (panelAnchorRef.current) observer.observe(panelAnchorRef.current)
return () => observer.disconnect()
}, [])
// Space for the parts right of the logo, measured: the file name gives way first (its CSS clamp subtracts
// --hm-sw-header-leading-width). Open moves into More only when the bar or object area really overflows,
// and returns once the header is wider than at that moment by the Open width (no flicker at the edge).
useLayoutEffect(() => {
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
if (!header) return
const leadingWidth = leadingRef.current ? leadingRef.current.getBoundingClientRect().width : 0
const value = hasLeading ? `${leadingWidth + 2}px` : ""
if (header.style.getPropertyValue("--hm-sw-header-leading-width") !== value) {
if (value) header.style.setProperty("--hm-sw-header-leading-width", value)
else header.style.removeProperty("--hm-sw-header-leading-width")
}
if (!openInHeader) {
collapseRef.current = null
if (openCollapsed) setOpenCollapsed(false)
return
}
if (!openCollapsed) {
const bar = header.querySelector<HTMLElement>(":scope > .hm-sw-header__bar")
const overflow = [bar, panelAnchorRef.current].some(element => !!element && element.scrollWidth - element.clientWidth > 1)
const open = leadingRef.current?.querySelector<HTMLElement>("[data-document-open]")
if (overflow && open) {
collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 }
setOpenCollapsed(true)
}
} else if (collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) {
setOpenCollapsed(false)
}
})
const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot)
const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0]
const validPromoted = promoted && enabled(promoted) ? promoted : null
@@ -302,7 +344,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
if (!item) return []
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id)
return [{ ...item, label: config.label ?? item.label, visible: scripted ? hidden : undefined }]
})
}).concat(openInHeader ? [{ id: "documentOpen", label: documentOpenLabel ?? "Open", tier: "action" as const, disabled, visible: openCollapsed, onSelect: onDocumentOpen }] : [])
const fieldNodes: Record<SWHeaderMenuId, ReactNode> = {
layer: (layers ? (
<SWHeaderLayerMenu label={label("layer", "레이어")}
@@ -318,9 +360,10 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
onFreezeChange={onLayerFreezeChange}
onLockChange={onLayerLockChange}
onDetail={onLayerDetail}
{...layerStates}
/>
) : (
<SWHeaderLayerControl label={label("layer", "레이어")} value={layer} selected={activeMenu === "layer"} disabled={disabled} onClick={onLayerOpen} />
<SWHeaderLayerControl label={label("layer", "레이어")} value={layer} selected={activeMenu === "layer"} disabled={disabled} onClick={onLayerOpen} {...layerStates} />
)),
color: (colors ? (
<SWHeaderColorMenu label={label("color", "색상")}
@@ -404,6 +447,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
onDocumentClose={onDocumentClose}
onNew={onDocumentNew}
onOpen={onDocumentOpen}
showNew={shown("documentNew")}
showOpen={shown("documentOpen") && !openInHeader}
documentIcon={documentIcon}
newLabel={documentNewLabel}
openLabel={documentOpenLabel}
@@ -424,16 +469,21 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const windowReduced = removed.size > 0 && (frameControls.some(id => removed.has(id)) || (controls === undefined && !(enabled("print") && enabled("popupGuide"))))
// CSS-only default layout: a field holding the first slot instead of the removed layer is styled as promoted.
const promotedSlot = validPromoted ?? (!scripted && baseMenu && baseMenu !== "layer" ? baseMenu : null)
const barData = propertyBar ?? (propertyPanel && {
// The bar shares the panel data only when the panel has a selection (a content-only panel has no bar rows).
const barData = propertyBar ?? (propertyPanel?.selection && {
selection: propertyPanel.selection,
properties: propertyPanel.properties,
properties: propertyPanel.properties ?? [],
onPropertyChange: propertyPanel.onPropertyChange,
})
return (
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} {...rest}>
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} {...rest}>
<div className="hm-sw-header__bar">
<SWHeaderHome onClick={shown("home") ? onHomeClick : undefined} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt} />
{hasLeading && <div ref={leadingRef} className="hm-sw-header__leading">
{openShown && <SWToolButton appearance="document" data-document-open="" disabled={disabled} onClick={onDocumentOpen}>{documentOpenLabel ?? "Open"}</SWToolButton>}
{leading}
</div>}
{controls === undefined && enabled("document") && documentNode}
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined}>
{controls === undefined && enabled("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
@@ -446,7 +496,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
)) : menuIds.map(id => <Fragment key={id}>{fieldNodes[id]}</Fragment>)}
{tools}
</div>
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} />}
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} forceVisible={openCollapsed} />}
{controls === undefined && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
</div>
{windowShown && <div className="hm-sw-header__window" data-configured={controls !== undefined || undefined} data-reduced={windowReduced || undefined}>
@@ -455,7 +505,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
</div>}
</div>
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, maxWidth: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} /></div>}
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} menus={{ showLayerFreeze: layerStates.showFreeze, showLayerLock: layerStates.showLock, ...propertyPanel.menus }} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
</header>
)
})
@@ -33,6 +33,12 @@ export interface SWHeaderDocumentTitleProps {
documentIcon?: ReactNode
newLabel?: string
openLabel?: string
/**
* Footer actions for products without that function (default true). Hiding both removes the footer;
* hiding Open avoids a duplicate when the Header shows its own Open button next to the logo.
*/
showNew?: boolean
showOpen?: boolean
}
/**
@@ -55,8 +61,11 @@ export function SWHeaderDocumentTitle({
documentIcon: iconOverride,
newLabel = "+ New",
openLabel = "Open",
showNew = true,
showOpen = true,
}: SWHeaderDocumentTitleProps) {
const hasMenu = documents !== undefined
// Nothing to list and no footer action: the file name stays a plain button (no empty dropdown).
const hasMenu = documents !== undefined && (documents.length > 0 || showNew || showOpen)
const [internalOpen, setInternalOpen] = useState(false)
const open = hasMenu && (menuOpen ?? internalOpen)
const rootRef = useRef<HTMLDivElement>(null)
@@ -173,10 +182,10 @@ export function SWHeaderDocumentTitle({
))}
</ul>
)}
<div className="hm-sw-header__document-footer">
<button type="button" className="hm-sw-header__document-action" onClick={() => { onNew?.(); close(true) }}>{newLabel}</button>
<button type="button" className="hm-sw-header__document-action" onClick={() => { onOpen?.(); close(true) }}>{openLabel}</button>
</div>
{(showNew || showOpen) && <div className="hm-sw-header__document-footer">
{showNew && <button type="button" className="hm-sw-header__document-action" data-action="new" onClick={() => { onNew?.(); close(true) }}>{newLabel}</button>}
{showOpen && <button type="button" className="hm-sw-header__document-action" data-action="open" onClick={() => { onOpen?.(); close(true) }}>{openLabel}</button>}
</div>}
</div>
)}
</div>
@@ -1,4 +1,4 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent } from "react"
import { useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type KeyboardEvent, type RefObject } from "react"
import detailIcon from "../../../assets/layer-detail.svg"
import type { ButtonHTMLAttributes, ReactNode } from "react"
@@ -9,6 +9,45 @@ export interface SWHeaderDropdownOptions {
disabled?: boolean
/** `start`: left edge under the field (color, text, dimension). `center`: centered under it (line style). */
align?: "start" | "center"
/** Inside an SW popup (scrolling, clipping body) the list becomes a fixed overlay. Default true. */
escapePopup?: boolean
}
export interface SWFixedDropdownPosition { left: number; top: number; flipped: boolean; offset: number }
/**
* Fixed overlay position of a field dropdown, kept on screen: below the field (or above it when only there is
* room), aligned to the field start / end / center and shifted sideways only at the screen edge. Used by the
* property window pickers and by every field dropdown placed inside an SW popup, whose body scrolls and clips.
* `offset` is the panel left relative to the field.
*/
export function useSWFixedDropdownPosition(open: boolean, rootRef: RefObject<HTMLElement | null>, panelRef: RefObject<HTMLElement | null>, align: "start" | "end" | "center", enabled = true) {
const [position, setPosition] = useState<SWFixedDropdownPosition | null>(null)
useLayoutEffect(() => {
if (!open || !enabled) { setPosition(null); return }
const update = () => {
const field = rootRef.current?.getBoundingClientRect()
const panel = panelRef.current
if (!field || !panel) return
const width = panel.offsetWidth, height = panel.offsetHeight
const viewportWidth = document.documentElement.clientWidth, viewportHeight = window.innerHeight
const start = align === "end" ? field.right - width : align === "center" ? field.left + Math.round((field.width - width) / 2) : field.left
const left = Math.max(4, Math.min(start, viewportWidth - width - 4))
const below = field.bottom
const flipped = below + height > viewportHeight - 4 && field.top - height >= 4
const top = flipped ? field.top - height : below
const offset = Math.round(left - field.left)
setPosition(current => (current && current.left === left && current.top === top && current.flipped === flipped ? current : { left, top, flipped, offset }))
}
update()
window.addEventListener("resize", update)
window.addEventListener("scroll", update, true)
return () => {
window.removeEventListener("resize", update)
window.removeEventListener("scroll", update, true)
}
}, [open, enabled, align, rootRef, panelRef])
return position
}
/**
@@ -18,7 +57,7 @@ export interface SWHeaderDropdownOptions {
* a click outside / Tab out close. The list overlays below the field and is shifted horizontally only
* when it would leave the header (or the screen).
*/
export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = false, align = "start" }: SWHeaderDropdownOptions) {
export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = false, align = "start", escapePopup = true }: SWHeaderDropdownOptions) {
const [internalOpen, setInternalOpen] = useState(false)
const open = !disabled && (openProp ?? internalOpen)
const rootRef = useRef<HTMLDivElement>(null)
@@ -70,6 +109,9 @@ export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = f
return () => window.removeEventListener("resize", update)
}) // Reposition after a host-driven control reorder as well as open/resize.
const inPopup = open && escapePopup && !!rootRef.current?.closest(".hm-sw-property")
const fixed = useSWFixedDropdownPosition(open, rootRef, panelRef, align, inPopup)
/** Focusable entries of the panel in DOM order (rows, inputs, footer). */
const entries = () => Array.from(panelRef.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input:not(:disabled)") ?? [])
@@ -120,9 +162,11 @@ export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = f
triggerRef,
menuId,
/** Panel `left` relative to the field (centered and / or shifted to stay on screen). */
panelStyle: position.left !== 0 ? { left: position.left } : undefined,
panelStyle: fixed ? { position: "fixed" as const, left: fixed.left, top: fixed.top } : inPopup ? { visibility: "hidden" as const } : position.left !== 0 ? { left: position.left } : undefined,
/** Panel left relative to the field (also in the fixed popup overlay). */
panelOffset: fixed ? fixed.offset : position.left,
/** The panel no longer sits under the field (the outline then rings the panel only). */
shifted: position.left !== position.base,
shifted: fixed ? fixed.flipped || fixed.offset !== position.base : position.left !== position.base,
rootProps: { ref: rootRef, onKeyDown, onBlur, "data-open": open ? "true" : undefined },
}
}
@@ -18,15 +18,18 @@ export interface SWHeaderLayerControlProps extends FieldBase {
value: SWHeaderLayerValue
/** State of the current layer for the three field icons (gray when off). Defaults to all colored. */
state?: Pick<SWHeaderLayer, "visible" | "frozen" | "locked">
/** Products without freeze / lock: the icon is not drawn, its 20px place stays (default true). */
showFreeze?: boolean
showLock?: boolean
}
export function SWHeaderLayerControl({ value, state, label = "레이어", ...rest }: SWHeaderLayerControlProps) {
export function SWHeaderLayerControl({ value, state, label = "레이어", showFreeze = true, showLock = true, ...rest }: SWHeaderLayerControlProps) {
return (
<SWHeaderField label={label} variant="layer" {...rest}>
<span className="hm-sw-header__layer-icons" aria-hidden="true">
<SWHeaderLayerIconArt kind="visibility" on={layerIconOn("visibility", state)} />
<SWHeaderLayerIconArt kind="freeze" on={layerIconOn("freeze", state)} />
<SWHeaderLayerIconArt kind="lock" on={layerIconOn("lock", state)} />
{showFreeze ? <SWHeaderLayerIconArt kind="freeze" on={layerIconOn("freeze", state)} /> : <span className="hm-sw-header__layer-icon" data-empty="" />}
{showLock ? <SWHeaderLayerIconArt kind="lock" on={layerIconOn("lock", state)} /> : <span className="hm-sw-header__layer-icon" data-empty="" />}
</span>
<SWHeaderFieldText label={label} value={value.name} />
</SWHeaderField>
@@ -1,5 +1,5 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"
import { SWHeaderMenuDetail } from "./SWHeaderDropdown"
import { SWHeaderMenuDetail, useSWFixedDropdownPosition } from "./SWHeaderDropdown"
import type { SWHeaderLayer, SWHeaderLayerValue } from "../types"
import { SWHeaderLayerControl } from "./SWHeaderFields"
import { SWHeaderLayerToggle } from "./SWHeaderLayerIcons"
@@ -26,6 +26,12 @@ export interface SWHeaderLayerMenuProps {
onLockChange?: (layer: SWHeaderLayer, nextLocked: boolean) => void
/** "레이어 상세". Optional host action; omitting it does not disable hover or simulate CAD work. */
onDetail?: () => void
/**
* Products without freeze / lock (default true): the snowflake / lock is not drawn in the field or in
* any row and is no button; its 20px place stays so names and the other icons keep their positions.
*/
showFreeze?: boolean
showLock?: boolean
}
/**
@@ -49,6 +55,8 @@ export function SWHeaderLayerMenu({
onFreezeChange,
onLockChange,
onDetail,
showFreeze = true,
showLock = true,
}: SWHeaderLayerMenuProps) {
const [internalOpen, setInternalOpen] = useState(false)
const open = !disabled && (openProp ?? internalOpen)
@@ -150,6 +158,9 @@ export function SWHeaderLayerMenu({
}
const current = layers.find(item => item.id === activeLayerId)
// Inside an SW popup the scrolling body would clip the list: the shared fixed overlay takes over.
const inPopup = open && !!rootRef.current?.closest(".hm-sw-property")
const fixed = useSWFixedDropdownPosition(open, rootRef, menuRef, "start", inPopup)
return (
<div
@@ -167,6 +178,8 @@ export function SWHeaderLayerMenu({
label={label}
value={current ? { name: current.name } : value}
state={current}
showFreeze={showFreeze}
showLock={showLock}
selected={selected || open}
expanded={open}
controls={open ? menuId : undefined}
@@ -183,8 +196,8 @@ export function SWHeaderLayerMenu({
className="hm-sw-header__layer-menu"
role="group"
aria-label="레이어 목록"
data-shifted={offset !== 0 ? "true" : undefined}
style={offset !== 0 ? { left: offset } : undefined}
data-shifted={(fixed ? fixed.flipped || fixed.offset !== 0 : offset !== 0) ? "true" : undefined}
style={fixed ? { position: "fixed", left: fixed.left, top: fixed.top } : inPopup ? { visibility: "hidden" } : offset !== 0 ? { left: offset } : undefined}
>
<div className="hm-sw-header__layer-panel">
<SWHeaderLayerRows
@@ -198,6 +211,8 @@ export function SWHeaderLayerMenu({
onVisibilityChange={onVisibilityChange}
onFreezeChange={onFreezeChange}
onLockChange={onLockChange}
showFreeze={showFreeze}
showLock={showLock}
onDetail={onDetail && (() => { onDetail(); close(true) })}
/>
</div>
@@ -207,7 +222,7 @@ export function SWHeaderLayerMenu({
)
}
export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "onVisibilityChange" | "onFreezeChange" | "onLockChange"> {
export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "onVisibilityChange" | "onFreezeChange" | "onLockChange" | "showFreeze" | "showLock"> {
layers: SWHeaderLayer[]
/** Row shown as current (#FFC600 name, hover emphasis). None when the selection mixes layers. */
current?: SWHeaderLayer
@@ -222,7 +237,7 @@ export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "on
* Shared by the header layer field and the property window layer field: same rows, toggles and callbacks.
* The bulb, snowflake and lock are siblings of the row selection, so they never select the row.
*/
export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChange, onFreezeChange, onLockChange, onDetail, detailDisabled = false }: SWHeaderLayerRowsProps) {
export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChange, onFreezeChange, onLockChange, onDetail, detailDisabled = false, showFreeze = true, showLock = true }: SWHeaderLayerRowsProps) {
return (
<>
{layers.length > 0 && (
@@ -236,8 +251,8 @@ export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChang
>
<span className="hm-sw-header__layer-toggles">
<SWHeaderLayerToggle kind="visibility" layer={layer} onChange={onVisibilityChange} />
<SWHeaderLayerToggle kind="freeze" layer={layer} onChange={onFreezeChange} />
<SWHeaderLayerToggle kind="lock" layer={layer} onChange={onLockChange} />
{showFreeze ? <SWHeaderLayerToggle kind="freeze" layer={layer} onChange={onFreezeChange} /> : <span className="hm-sw-header__layer-toggle" data-empty="" />}
{showLock ? <SWHeaderLayerToggle kind="lock" layer={layer} onChange={onLockChange} /> : <span className="hm-sw-header__layer-toggle" data-empty="" />}
</span>
<button
type="button"
@@ -60,7 +60,7 @@ export function SWHeaderLineStyleMenu({
value, label, lineTypes = [], lineWeights = [], activeLineTypeId, activeLineWeightId, open: openProp, onOpenChange, onClick,
selected = false, disabled = false, onLineTypeSelect, onLineWeightSelect, onLineWeightValueChange, onScaleChange, onDetail,
}: SWHeaderLineStyleMenuProps) {
const { open, setOpen, close, panelRef, triggerRef, menuId, panelStyle, rootProps } = useSWHeaderDropdown({ open: openProp, onOpenChange, disabled, align: "center" })
const { open, setOpen, close, panelRef, triggerRef, menuId, panelStyle, panelOffset, rootProps } = useSWHeaderDropdown({ open: openProp, onOpenChange, disabled, align: "center" })
const currentType = lineTypes.find(item => (activeLineTypeId === undefined ? item.name === value.name : item.id === activeLineTypeId))
const currentWeight = lineWeights.find(item => (activeLineWeightId === undefined ? item.name === value.weight : item.id === activeLineWeightId))
@@ -94,7 +94,7 @@ export function SWHeaderLineStyleMenu({
/>
{open && (
<div ref={panelRef} id={menuId} className="hm-sw-header__menu-panel hm-sw-header__line-menu" role="group" aria-label="선스타일"
style={{ ...panelStyle, "--hm-sw-line-seam-left": `${-(panelStyle?.left ?? 0)}px` } as CSSProperties}>
style={{ ...panelStyle, ...({ "--hm-sw-line-seam-left": `${-panelOffset}px` } as CSSProperties) }}>
<SWHeaderLineStyleColumns
lineTypes={lineTypes}
lineWeights={lineWeights}
@@ -25,10 +25,12 @@ export interface SWHeaderMoreItem {
export interface SWHeaderMoreProps {
items: SWHeaderMoreItem[]
/** Shows the trigger at any width (an item moved in by measurement, not by a breakpoint). */
forceVisible?: boolean
}
/** Overflow menu that keeps every moved control reachable. */
export function SWHeaderMore({ items }: SWHeaderMoreProps) {
export function SWHeaderMore({ items, forceVisible }: SWHeaderMoreProps) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
@@ -64,7 +66,7 @@ export function SWHeaderMore({ items }: SWHeaderMoreProps) {
}
return (
<div ref={rootRef} className="hm-sw-header__more" onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
<div ref={rootRef} className="hm-sw-header__more" data-forced={forceVisible || undefined} onKeyDown={onKeyDown} onPointerLeave={() => setGuideOpen(false)}>
<button
ref={triggerRef}
type="button"
@@ -1,7 +1,7 @@
import { useLayoutEffect, useState, type CSSProperties, type ReactNode, type Ref } from "react"
import { type CSSProperties, type ReactNode, type Ref } from "react"
import type { SWHeaderColorOption, SWHeaderLayer, SWHeaderLineType } from "../types"
import { SWHeaderColorPanelBody, useSWHeaderColorMode } from "./SWHeaderColorMenu"
import { useSWHeaderDropdown } from "./SWHeaderDropdown"
import { useSWFixedDropdownPosition, useSWHeaderDropdown } from "./SWHeaderDropdown"
import { SWHeaderInput, parseSWHeaderColor } from "./SWHeaderInput"
import { SWHeaderLayerRows } from "./SWHeaderLayerMenu"
import { SWHeaderLinePreview, SWHeaderLineStyleColumns } from "./SWHeaderLineStyleMenu"
@@ -38,30 +38,9 @@ const byValue = <T extends { id: string; name: string }>(items: readonly T[], va
* the list flips above the field and shifts sideways only when the screen has no room.
*/
function useFieldDropdown(align: "start" | "end", disabled: boolean) {
const dropdown = useSWHeaderDropdown({ disabled })
const { open, rootRef, panelRef } = dropdown
const [position, setPosition] = useState<CSSProperties>({ visibility: "hidden" })
useLayoutEffect(() => {
if (!open) return
const update = () => {
const field = rootRef.current?.getBoundingClientRect()
const panel = panelRef.current
if (!field || !panel) return
const width = panel.offsetWidth, height = panel.offsetHeight
const viewportWidth = document.documentElement.clientWidth, viewportHeight = window.innerHeight
const left = Math.max(4, Math.min(align === "end" ? field.right - width : field.left, viewportWidth - width - 4))
const below = field.bottom
const top = below + height > viewportHeight - 4 && field.top - height >= 4 ? field.top - height : below
setPosition(current => (current.left === left && current.top === top ? current : { left, top }))
}
update()
window.addEventListener("resize", update)
window.addEventListener("scroll", update, true)
return () => {
window.removeEventListener("resize", update)
window.removeEventListener("scroll", update, true)
}
}, [open, align, rootRef, panelRef])
const dropdown = useSWHeaderDropdown({ disabled, escapePopup: false })
const fixed = useSWFixedDropdownPosition(dropdown.open, dropdown.rootRef, dropdown.panelRef, align)
const position: CSSProperties = fixed ? { left: fixed.left, top: fixed.top } : { visibility: "hidden" }
return { ...dropdown, position }
}
@@ -125,6 +104,8 @@ export function SWHeaderPropertyPickerField({ picker, property, id, editable, me
onVisibilityChange={menus?.onLayerVisibilityChange}
onFreezeChange={menus?.onLayerFreezeChange}
onLockChange={menus?.onLayerLockChange}
showFreeze={menus?.showLayerFreeze}
showLock={menus?.showLayerLock}
onDetail={menus?.onLayerDetail && (() => { menus.onLayerDetail?.(); close(true) })}
/>
))}
@@ -1,4 +1,4 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type ReactNode, type RefObject } from "react"
import circleGraphic from "../../../assets/property-circle.svg"
import titleGraphic from "../../../assets/property-title.svg"
import singleTitleGraphic from "../../../assets/property-title-single.svg"
@@ -59,6 +59,9 @@ export interface SWHeaderPropertyMenus {
onLayerFreezeChange?: (layer: SWHeaderLayer, nextFrozen: boolean) => void
onLayerLockChange?: (layer: SWHeaderLayer, nextLocked: boolean) => void
onLayerDetail?: () => void
/** Products without freeze / lock: the state is not drawn in the layer list (its place stays). Default true. */
showLayerFreeze?: boolean
showLayerLock?: boolean
lineTypes?: SWHeaderLineType[]
/** Weight column of the line style dropdown; omitted => no column. */
lineWeights?: SWHeaderLineWeight[]
@@ -108,9 +111,20 @@ export interface SWHeaderPropertyShapeContext {
*/
export type SWHeaderPropertyShapeContent = ReactNode | ((context: SWHeaderPropertyShapeContext) => ReactNode)
export interface SWHeaderPropertyPanelProps {
selection: SWHeaderPropertySelection
properties: readonly SWHeaderProperty[]
groups: readonly SWHeaderPropertyGroup[]
/** Optional (2026-10-19): without it the panel is a plain popup (no selection tool) titled by `title`. */
selection?: SWHeaderPropertySelection
/** Optional (default []): data rows; combine with or replace by `children`. */
properties?: readonly SWHeaderProperty[]
groups?: readonly SWHeaderPropertyGroup[]
/** Title text; overrides `selection.title`. */
title?: string
/**
* Free content of the scrolling body, after the property groups (before the selection counts). Any React
* content: SW controls, @hm-design/ui components, the project's own fields. The frame never changes.
*/
children?: ReactNode
/** Focus the window when it opens. Default: true with `anchorRef` (SWHeader), false standalone. */
autoFocus?: boolean
onPropertyChange?: (property: SWHeaderProperty, value: string) => void
/** Lists and callbacks of the layer / line style / color field dropdowns. */
menus?: SWHeaderPropertyMenus
@@ -247,81 +261,159 @@ function PropertyEditor({ property, onChange, menus }: { property: SWHeaderPrope
)
}
/**
* Escape among several open popups: every mounted popup is on a stack ordered by its last activation (open,
* focus or pointer inside). Escape inside a popup closes that popup; Escape elsewhere closes only the top one.
* An Escape already handled inside (preventDefault / stopPropagation, e.g. a dropdown or an edited input)
* never closes the popup.
*/
const popupStack: { close: () => void }[] = []
function onDocumentEscape(event: KeyboardEvent) {
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return
popupStack.at(-1)?.close()
}
function useSWPopupStack(onClose?: () => void) {
const entry = useRef({ close: () => {} })
entry.current.close = () => onClose?.()
useEffect(() => {
const item = entry.current
popupStack.push(item)
if (popupStack.length === 1) document.addEventListener("keydown", onDocumentEscape)
return () => {
popupStack.splice(popupStack.indexOf(item), 1)
if (popupStack.length === 0) document.removeEventListener("keydown", onDocumentEscape)
}
}, [])
/** Moves this popup to the top of the stack. */
return () => {
const index = popupStack.indexOf(entry.current)
if (index >= 0 && index !== popupStack.length - 1) popupStack.push(...popupStack.splice(index, 1))
}
}
export interface SWPopupFrameProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
title: string
/** "single": title art without the selection tool (default). "tool": art with the tool slot. */
titleArt?: "single" | "tool"
/** Buttons of the tool slot (property window selection tool). */
titleActions?: ReactNode
/** Accessible name of the close button. */
closeLabel?: string
onClose?: () => void
anchorRef?: RefObject<HTMLElement | null>
autoFocus?: boolean
/** Remounts the body when it changes (property window selection). */
bodyKey?: string
/** Rendered after the scrolling body (property window footer). */
after?: ReactNode
/** Value of data-selection-kind (frame CSS variants). */
kind?: SWHeaderPropertySelection["kind"]
}
/**
* The SW popup frame (Figma 26:11822): 314px, #E4DBC9 surface, #A67D03 border, 4px radius, 30px title bar with
* the Figma title art and close button, scrolling body (viewport-bound). Non-modal: no dim, no portal, no focus
* trap. Shared by SWPopup and SWHeaderPropertyPanel, so both render the same DOM and classes.
*/
export function SWPopupFrame({
title, titleArt = "single", titleActions, closeLabel = "속성창 닫기", onClose, anchorRef, autoFocus, bodyKey, after, kind = "single",
id, style: customStyle, className, children, onKeyDown, onFocusCapture, onPointerDownCapture, ...rest
}: SWPopupFrameProps) {
const titleId = useId()
const { panelRef, style } = useSWHeaderOverlay(anchorRef, 314)
const raise = useSWPopupStack(onClose)
const focus = autoFocus ?? !!anchorRef
useLayoutEffect(() => {
if (!focus) return
const panel = panelRef.current
const previous = document.activeElement as HTMLElement | null
panel?.focus()
return () => {
// Closing with focus still inside (e.g. Escape on a field) returns it to where it came from.
const active = document.activeElement
if (panel && (active === document.body || active === null || panel.contains(active)) && previous?.isConnected) previous.focus()
}
}, [focus, panelRef])
return (
<div ref={panelRef} id={id} tabIndex={-1} className={`hm-sw-property${className ? ` ${className}` : ""}`} data-selection-kind={kind} role="dialog" aria-modal="false" aria-labelledby={titleId}
style={{ ...style, ...customStyle }} {...rest}
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
onKeyDown={event => {
onKeyDown?.(event)
if (event.key === "Escape" && !event.defaultPrevented && !event.nativeEvent.isComposing) { event.stopPropagation(); onClose?.() }
}}>
<div className="hm-sw-property__titlebar">
<img className="hm-sw-property__title-art" src={titleArt === "single" ? singleTitleGraphic : titleGraphic} alt="" />
<span id={titleId} className="hm-sw-property__title" title={title}>{title}</span>
{titleActions}
<button type="button" className="hm-sw-property__close" aria-label={closeLabel} onClick={onClose}>
<img className="hm-sw-property__close-default" src={closeDefault} alt="" />
<img className="hm-sw-property__close-hover" src={closeHover} alt="" />
</button>
</div>
<div className="hm-sw-property__body" key={bodyKey}>{children}</div>
{after}
</div>
)
}
/** A single data-driven, non-modal property tool window (Figma 26:11822). */
export function SWHeaderPropertyPanel({
selection, properties, groups, onPropertyChange, menus, expandedGroupIds, defaultExpandedGroupIds = [],
selection: selectionProp, properties = [], groups = [], title, children, autoFocus, onPropertyChange, menus, expandedGroupIds, defaultExpandedGroupIds = [],
onExpandedGroupsChange, toolActive: controlledTool, onToolActiveChange, toolLabel = "객체 선택 도구",
onOverviewClick, shapeContent, onClose, id, anchorRef, style: customStyle,
onOverviewClick, shapeContent, onClose, id, anchorRef, style,
}: SWHeaderPropertyPanelProps) {
const titleId = useId()
const selection: SWHeaderPropertySelection = selectionProp ?? { id: "", title: title ?? "", kind: "single" }
const [expanded, setExpanded] = useState<string[]>([...defaultExpandedGroupIds])
const [internalTool, setInternalTool] = useState(false)
const active = controlledTool ?? internalTool
const expandedIds = expandedGroupIds ?? expanded
const { panelRef, style } = useSWHeaderOverlay(anchorRef, 314)
useLayoutEffect(() => { if (anchorRef) panelRef.current?.focus() }, [anchorRef, panelRef])
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented && !event.isComposing) onClose?.()
}
document.addEventListener("keydown", onKey)
return () => document.removeEventListener("keydown", onKey)
}, [onClose])
const toggleGroup = (groupId: string) => {
const next = expandedIds.includes(groupId) ? expandedIds.filter(id => id !== groupId) : [...expandedIds, groupId]
if (expandedGroupIds === undefined) setExpanded(next)
onExpandedGroupsChange?.(next)
}
return (
<div ref={panelRef} id={id} tabIndex={-1} className="hm-sw-property" data-selection-kind={selection.kind} role="dialog" aria-modal="false" aria-labelledby={titleId} style={{ ...style, ...customStyle }} onKeyDown={event => {
if (event.key === "Escape" && !event.nativeEvent.isComposing) { event.stopPropagation(); onClose?.() }
}}>
<div className="hm-sw-property__titlebar">
<img className="hm-sw-property__title-art" src={selection.kind === "single" ? singleTitleGraphic : titleGraphic} alt="" />
<span id={titleId} className="hm-sw-property__title" title={selection.title}>{selection.title}</span>
{selection.kind !== "single" && <button type="button" className="hm-sw-property__tool" aria-label={toolLabel} title={toolLabel} aria-pressed={active} onClick={() => {
if (controlledTool === undefined) setInternalTool(!active)
onToolActiveChange?.(!active)
}}>
<img className={active ? "hm-sw-property__tool-active" : "hm-sw-property__tool-default"} src={active ? toolActiveGraphic : toolDefault} alt="" />
{!active && <img className="hm-sw-property__tool-hover" src={toolHover} alt="" />}
</button>}
<button type="button" className="hm-sw-property__close" aria-label="속성창 닫기" onClick={onClose}>
<img className="hm-sw-property__close-default" src={closeDefault} alt="" />
<img className="hm-sw-property__close-hover" src={closeHover} alt="" />
</button>
</div>
<div className="hm-sw-property__body" key={selection.id}>
{ordered(groups).map(group => {
const items = ordered(properties.filter(property => property.groupId === group.id))
if (!items.length || (selection.kind === "none" && group.layout && group.layout !== "rows")) return null
const isExpanded = expandedIds.includes(group.id)
const expandable = group.collapsedCount !== undefined && items.length > group.collapsedCount
const visible = expandable && !isExpanded ? items.slice(0, group.collapsedCount) : items
return (
<section key={group.id} className={`hm-sw-property__group hm-sw-property__group--${group.layout ?? "rows"}`} aria-label={group.name ?? "공통 속성"}>
{group.name && <h3>{group.name}</h3>}
<div className="hm-sw-property__group-content">
{group.layout === "circle" && (() => {
const content = typeof shapeContent === "function" ? shapeContent({ selection, group, properties: items }) : shapeContent
const fallback = shapeContent === undefined
return <div className="hm-sw-property__shape" data-shape-slot={fallback ? "default" : content === null || content === false ? "empty" : "custom"}>
{fallback ? <img className="hm-sw-property__circle" src={circleGraphic} alt="원형 도형의 지름과 반지름 안내" /> : content}
</div>
})()}
{group.layout === "position" && <div className="hm-sw-property__position-head"><span>구분</span>{visible.map(item => <span key={item.id}>{item.name}</span>)}</div>}
<div className="hm-sw-property__editors">
{group.layout === "position" && <span className="hm-sw-property__position-label">{group.positionLabel}</span>}
{visible.map(property => <div key={property.id} className="hm-sw-property__editor" data-slot={property.graphicSlot}><PropertyEditor property={property} onChange={onPropertyChange} menus={menus} /></div>)}
<SWPopupFrame id={id} title={title ?? selection.title} kind={selection.kind} titleArt={selection.kind === "single" ? "single" : "tool"}
anchorRef={anchorRef} autoFocus={autoFocus} style={style} onClose={onClose} bodyKey={selection.id}
titleActions={selection.kind !== "single" && <button type="button" className="hm-sw-property__tool" aria-label={toolLabel} title={toolLabel} aria-pressed={active} onClick={() => {
if (controlledTool === undefined) setInternalTool(!active)
onToolActiveChange?.(!active)
}}>
<img className={active ? "hm-sw-property__tool-active" : "hm-sw-property__tool-default"} src={active ? toolActiveGraphic : toolDefault} alt="" />
{!active && <img className="hm-sw-property__tool-hover" src={toolHover} alt="" />}
</button>}
after={selection.overviewTitle && <button type="button" className="hm-sw-property__overview" onClick={onOverviewClick} disabled={!onOverviewClick}><img src={footerGraphic} alt="" /><span>{selection.overviewTitle}</span></button>}>
{ordered(groups).map(group => {
const items = ordered(properties.filter(property => property.groupId === group.id))
if (!items.length || (selection.kind === "none" && group.layout && group.layout !== "rows")) return null
const isExpanded = expandedIds.includes(group.id)
const expandable = group.collapsedCount !== undefined && items.length > group.collapsedCount
const visible = expandable && !isExpanded ? items.slice(0, group.collapsedCount) : items
return (
<section key={group.id} className={`hm-sw-property__group hm-sw-property__group--${group.layout ?? "rows"}`} aria-label={group.name ?? "공통 속성"}>
{group.name && <h3>{group.name}</h3>}
<div className="hm-sw-property__group-content">
{group.layout === "circle" && (() => {
const content = typeof shapeContent === "function" ? shapeContent({ selection, group, properties: items }) : shapeContent
const fallback = shapeContent === undefined
return <div className="hm-sw-property__shape" data-shape-slot={fallback ? "default" : content === null || content === false ? "empty" : "custom"}>
{fallback ? <img className="hm-sw-property__circle" src={circleGraphic} alt="원형 도형의 지름과 반지름 안내" /> : content}
</div>
{expandable && <button type="button" className="hm-sw-property__expand" aria-label={`${group.name ?? "공통 속성"} ${isExpanded ? "접기" : "펼치기"}`} aria-expanded={isExpanded} onClick={() => toggleGroup(group.id)}><img src={expandGraphic} alt="" /></button>}
})()}
{group.layout === "position" && <div className="hm-sw-property__position-head"><span>구분</span>{visible.map(item => <span key={item.id}>{item.name}</span>)}</div>}
<div className="hm-sw-property__editors">
{group.layout === "position" && <span className="hm-sw-property__position-label">{group.positionLabel}</span>}
{visible.map(property => <div key={property.id} className="hm-sw-property__editor" data-slot={property.graphicSlot}><PropertyEditor property={property} onChange={onPropertyChange} menus={menus} /></div>)}
</div>
</section>
)
})}
{selection.kind === "all" && !!selection.counts?.length && <div className="hm-sw-property__counts" aria-label="선택된 객체 종류와 개수">{selection.counts.map(item => <span key={item.id} style={{ gridColumn: item.column, gridRow: item.row }}>{item.name} {item.count}</span>)}</div>}
</div>
{selection.overviewTitle && <button type="button" className="hm-sw-property__overview" onClick={onOverviewClick} disabled={!onOverviewClick}><img src={footerGraphic} alt="" /><span>{selection.overviewTitle}</span></button>}
</div>
{expandable && <button type="button" className="hm-sw-property__expand" aria-label={`${group.name ?? "공통 속성"} ${isExpanded ? "접기" : "펼치기"}`} aria-expanded={isExpanded} onClick={() => toggleGroup(group.id)}><img src={expandGraphic} alt="" /></button>}
</div>
</section>
)
})}
{children}
{selection.kind === "all" && !!selection.counts?.length && <div className="hm-sw-property__counts" aria-label="선택된 객체 종류와 개수">{selection.counts.map(item => <span key={item.id} style={{ gridColumn: item.column, gridRow: item.row }}>{item.name} {item.count}</span>)}</div>}
</SWPopupFrame>
)
}
@@ -30,4 +30,4 @@ export type {
} from "./types"
export { SWHeaderPropertyBar, type SWHeaderPropertyBarProps } from "./components/SWHeaderPropertyBar"
export type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId } from "./types"
export type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderSubControlId, SWHeaderHideableControlId } from "./types"
+23 -1
View File
@@ -138,8 +138,14 @@ export interface SWHeaderDimensionStyleOption {
export type SWHeaderControlId = "document" | SWHeaderMenuId | "drawingMode" | "details" | "propertyBar" | "propertyPanel" | "print" | "popupGuide"
/** Common frame parts outside the object area. Their position is fixed; each can be removed. */
export type SWHeaderFrameControlId = "home" | "windowMove" | "minimize" | "close"
/**
* Items inside a part, for products without that function: the document dropdown's `+ New` / `Open`
* and the layer freeze / lock state. Removing one never changes the layout or the breakpoints; a layer
* state keeps its 20px place (empty), so names and the other icons stay aligned.
*/
export type SWHeaderSubControlId = "documentNew" | "documentOpen" | "layerFreeze" | "layerLock"
/** Every Header part except the logo, which is always shown. */
export type SWHeaderHideableControlId = SWHeaderControlId | SWHeaderFrameControlId
export type SWHeaderHideableControlId = SWHeaderControlId | SWHeaderFrameControlId | SWHeaderSubControlId
export interface SWHeaderControlConfig {
id: SWHeaderControlId
visible?: boolean
@@ -154,6 +160,9 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
* Parts to remove, in either layout (default design or `controls`). Works without passing a full
* `controls` list, so the default design and order of the remaining parts are kept. Removing
* "home" keeps the logo as a static image. Unknown ids are ignored. Default: none.
* Sub-items (`SWHeaderSubControlId`): "documentNew" / "documentOpen" remove the document dropdown
* actions (both: no footer), "layerFreeze" / "layerLock" remove that state from the layer field,
* the layer dropdown and the property window layer list.
*/
hiddenControls?: readonly SWHeaderHideableControlId[]
/**
@@ -187,6 +196,19 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
onDocumentOpen?: () => void
/** Default icon for documents without `icon`. Defaults to the Figma document icon. */
documentIcon?: ReactNode
/**
* Where "Open" is offered. `menu` (default): the document dropdown footer (unchanged). `header`: an Open
* button right of the logo in the file-name look (`SWToolButton appearance="document"`), and no Open in the
* dropdown, so it never appears twice. Both call `onDocumentOpen` with the `documentOpenLabel` text.
* When the header lacks room (measured), the button moves into More. `hiddenControls: ["documentOpen"]`
* removes Open everywhere.
*/
documentOpenPlacement?: "menu" | "header"
/**
* Extra controls right of the logo (after the header Open button, before the file name), e.g.
* `SWToolButton appearance="document"`. They keep their place at every width; the file name gives way.
*/
leading?: ReactNode
/** Footer labels. Default `+ New` / `Open` (Figma). */
documentNewLabel?: string
documentOpenLabel?: string
@@ -0,0 +1,97 @@
import { cloneElement, isValidElement, type HTMLAttributes, type MouseEvent, type ReactNode, type RefObject } from "react"
import { SWPopupFrame } from "../sw-header/components/SWHeaderPropertyPanel"
export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
/** Title text (700, #FFC600, ellipsis). */
title: string
/** Close button and Escape (inside the popup, or anywhere while it is the top popup). */
onClose?: () => void
/** Accessible name of the close button. Default `닫기`. */
closeLabel?: string
/**
* Free content of the scrolling body: SWPopupSection / SWPopupRow, SW controls, @hm-design/ui components or
* the project's own elements. Nothing is restricted; the frame keeps the SW design whatever is inside.
*/
children?: ReactNode
/** Focus the popup when it opens (and return focus when it closes from inside). Default false. */
autoFocus?: boolean
/** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */
anchorRef?: RefObject<HTMLElement | null>
}
/**
* HMDesign SW popup (the property window frame, Figma 26:11822) with free content. 314px wide, height follows the
* content up to the viewport (the body scrolls). Non-modal (no dim, no portal, no focus trap); the host decides
* when it is shown and where (`style`, or `anchorRef`). Wrap it in <SWTheme> outside SWHeader.
*/
export function SWPopup({ closeLabel = "닫기", ...props }: SWPopupProps) {
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" {...props} />
}
export interface SWPopupSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
/** Group heading (700, 70% black). Also the section's accessible name. */
title?: string
children?: ReactNode
}
/** A content group of the popup, the property window group (`rows` layout). Holds SWPopupRow or any content. */
export function SWPopupSection({ title, children, className, ...rest }: SWPopupSectionProps) {
return (
<section className={`hm-sw-property__group hm-sw-property__group--rows${className ? ` ${className}` : ""}`} aria-label={rest["aria-label"] ?? title} {...rest}>
{title && <h3>{title}</h3>}
<div className="hm-sw-property__group-content"><div className="hm-sw-property__editors">{children}</div></div>
</section>
)
}
export interface SWPopupRowProps extends HTMLAttributes<HTMLDivElement> {
/** Row label (72px, 700, ellipsis). */
label: string
/** Id of the control in `children`, for the label. */
htmlFor?: string
/** Unit text after the value (12px). */
unit?: string
/**
* Value well: `box` (default) the 200px property field (Default / Hover border), `compact` the 41px hug field
* of a bare number (children must be one SWHeaderInput), `none` no well (switches, buttons, text).
*/
field?: "box" | "compact" | "none"
/** Number editing look (Figma On #0F0D0A while focused); text values keep Default / Hover only. */
numeric?: boolean
/** Read-only value: no hover / editing look. */
readOnly?: boolean
/** 40% row. */
disabled?: boolean
children?: ReactNode
}
/**
* One label + value row of the property window. The value is `children` (SWHeaderInput for SW fields, or any
* control). A click on the empty part of a `box` focuses its first input, like the property window.
*/
export function SWPopupRow({ label, htmlFor, unit, field = "box", numeric, readOnly, disabled, children, className, ...rest }: SWPopupRowProps) {
const focusInput = (event: MouseEvent<HTMLDivElement>) => {
if (field !== "box" || disabled || readOnly) return
const target = event.target as HTMLElement
if (target.closest("input, select, textarea, button, a, [tabindex]")) return
const input = event.currentTarget.querySelector<HTMLElement>("input, select, textarea")
if (!input) return
event.preventDefault()
input.focus()
}
return (
<div className={`hm-sw-property__editor${className ? ` ${className}` : ""}`} {...rest}>
<div className="hm-sw-property__row" data-disabled={disabled || undefined}>
<label htmlFor={htmlFor} title={label}>{label}</label>
<div className="hm-sw-property__value" data-field={field === "none" ? undefined : field === "box" ? "box" : undefined}
data-on={field === "box" && numeric && !readOnly ? "edit" : undefined} data-readonly={readOnly ? "true" : undefined}
onMouseDown={focusInput}>
{field === "compact" && isValidElement<{ className?: string }>(children)
? cloneElement(children, { className: `${children.props.className ? `${children.props.className} ` : ""}hm-sw-property__compact` })
: children}
{unit && <span className="hm-sw-property__unit">{unit}</span>}
</div>
</div>
</div>
)
}
@@ -0,0 +1,117 @@
import { createContext, forwardRef, useContext, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
/**
* Existing SW button looks, reused as one public button (no new design values):
* - `text`: the 속성Bar / 속성창 text toggle (700, text-only hover #FFC600, selected #FFC600).
* - `pill`: the 40 x 38 print / K button of the dark pill (gradient hover ring, optional hover icon).
* - `document`: the file-name surface (`hm-sw-header__document-button`, text-only hover #FFC600). Placed right
* after `SWHeaderHome` it shares the slanted logo junction exactly like the file name.
*/
export type SWToolButtonAppearance = "text" | "pill" | "document"
export interface SWToolButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
/** Default `text`. */
appearance?: SWToolButtonAppearance
/** Toggle state (aria-pressed, #FFC600 text). Inside `SWToolGroup selection="single"` it is the checked radio. */
pressed?: boolean
/** Button text. For `pill` it is the accessible name only (visually hidden: the pill shows its icon). */
children?: ReactNode
/** `pill` only: icon centered in the 40 x 38 button. */
icon?: ReactNode
/** `pill` only: icon shown instead of `icon` on hover (like print-hover.svg). */
hoverIcon?: ReactNode
}
interface GroupContext { selection: "none" | "single" }
const SWToolGroupContext = createContext<GroupContext | null>(null)
export const SWToolButton = forwardRef<HTMLButtonElement, SWToolButtonProps>(function SWToolButton(
{ appearance = "text", pressed, children, icon, hoverIcon, className, type = "button", ...rest }, ref,
) {
const group = useContext(SWToolGroupContext)
const radio = group?.selection === "single"
const state = pressed === undefined ? {} : radio
? { role: "radio", "aria-checked": pressed, tabIndex: rest.tabIndex ?? (pressed ? 0 : -1) }
: { "aria-pressed": pressed }
const classes = (base: string) => `${base}${className ? ` ${className}` : ""}`
if (appearance === "document") {
// The wrapper is the Header flex item; [data-tool] keeps it at its content width (not the file-name clamp).
return (
<div className="hm-sw-header__document" data-tool="">
<button ref={ref} type={type} className={classes("hm-sw-header__document-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
<span className="hm-sw-header__document-name">{children}</span>
</button>
</div>
)
}
if (appearance === "pill") {
return (
<button ref={ref} type={type} className={classes("hm-sw-header__action hm-sw-header__action--print hm-sw-tool__pill-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
{icon !== undefined && <span className="hm-sw-header__icon hm-sw-tool__icon" aria-hidden="true">{icon}</span>}
{hoverIcon !== undefined && <span className="hm-sw-header__icon-hover hm-sw-tool__icon" aria-hidden="true">{hoverIcon}</span>}
{children !== undefined && <span className="hm-sw-tool__label">{children}</span>}
</button>
)
}
return (
<button ref={ref} type={type} className={classes("hm-sw-header__toggle hm-sw-tool__text-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
{children}
</button>
)
})
/**
* Existing SW group surfaces:
* - `text`: the 상세보기 group (optional 12px caption above a 19px row; 1px dividers between buttons).
* - `pill`: the dark rounded print / K pill (shrinks to its buttons).
* `selection="single"` makes it a radio group (e.g. Model / Layout): give each button `pressed`, arrows move.
*/
export interface SWToolGroupProps extends HTMLAttributes<HTMLDivElement> {
/** Default `text`. */
appearance?: "text" | "pill"
/** `text` only: the 12px caption above the row, e.g. `상세보기`. Also the group's accessible name. */
label?: string
/** `single`: one pressed button (radio group, arrow keys move and select by focus only). Default `none`. */
selection?: "none" | "single"
children: ReactNode
}
function moveFocus(event: KeyboardEvent<HTMLDivElement>) {
const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End"]
if (!keys.includes(event.key)) return
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="radio"]:not(:disabled)'))
const index = items.indexOf(document.activeElement as HTMLButtonElement)
if (index < 0 || !items.length) return
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
: (index + (event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length
items[next].focus()
items[next].click()
}
export const SWToolGroup = forwardRef<HTMLDivElement, SWToolGroupProps>(function SWToolGroup(
{ appearance = "text", label, selection = "none", children, className, onKeyDown, ...rest }, ref,
) {
const role = selection === "single" ? "radiogroup" : "group"
const keys = (event: KeyboardEvent<HTMLDivElement>) => { onKeyDown?.(event); if (!event.defaultPrevented && selection === "single") moveFocus(event) }
const items = Array.isArray(children) ? children.filter(child => child !== null && child !== undefined && child !== false) : [children]
const content = (
<SWToolGroupContext.Provider value={{ selection }}>
{appearance === "text" ? items.map((child, index) => (
<span key={index} className="hm-sw-tool__item">
{index > 0 && <span className="hm-sw-header__properties-divider" aria-hidden="true" />}
{child}
</span>
)) : children}
</SWToolGroupContext.Provider>
)
if (appearance === "pill") {
return <div ref={ref} role={role} aria-label={rest["aria-label"] ?? label} className={`hm-sw-header__actions hm-sw-tool__pill${className ? ` ${className}` : ""}`} data-partial="" onKeyDown={keys} {...rest}>{content}</div>
}
return (
<div ref={ref} role={role} aria-label={rest["aria-label"] ?? label} className={`hm-sw-header__properties hm-sw-tool__group${className ? ` ${className}` : ""}`} data-partial="" data-caption={label ? "" : undefined} onKeyDown={keys} {...rest}>
{label && <span className="hm-sw-header__properties-caption" aria-hidden="true">{label}</span>}
<div className="hm-sw-header__properties-row">{content}</div>
</div>
)
})
+8
View File
@@ -2,3 +2,11 @@ import "./theme/sw.css"
export * from "./components/sw-header"
export { SWTheme, type SWThemeProps } from "./components/sw-theme/SWTheme"
export {
SWToolButton, SWToolGroup,
type SWToolButtonProps, type SWToolButtonAppearance, type SWToolGroupProps,
} from "./components/sw-tool/SWTool"
export {
SWPopup, SWPopupSection, SWPopupRow,
type SWPopupProps, type SWPopupSectionProps, type SWPopupRowProps,
} from "./components/sw-popup/SWPopup"
+43 -7
View File
@@ -377,7 +377,7 @@
display: flex;
flex-direction: column;
width: calc(var(--hm-sw-header-document-menu-width) + 2 * var(--hm-sw-header-document-menu-border-width));
max-width: calc(100cqw - 71px);
max-width: calc(100cqw - 71px - var(--hm-sw-header-leading-width, 0px));
border: var(--hm-sw-header-document-menu-border-width) solid var(--hm-sw-header-document-menu-border-color);
border-radius: 0 0 4px 4px;
overflow: hidden;
@@ -475,6 +475,8 @@
border-top: 1px solid var(--hm-sw-header-document-menu-divider-color);
box-shadow: var(--hm-sw-header-document-menu-inset-shadow);
}
/* A single remaining action keeps its Figma side: Open stays right, + New stays left. */
.hm-sw-header__document-footer > [data-action="open"]:only-child { margin-left: auto; }
.hm-sw-header__document-action {
height: 24px;
padding: 5px 6px;
@@ -1724,7 +1726,7 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
The file name always takes the remaining width (ellipsis). cqw = header width.
Hiding targets the flex item of each field ([data-menu] / [data-tier] on the field button, or on
the wrapper of a field with a dropdown), so a dropdown field hides like a plain one. */
.hm-sw-header__document { flex: 0 0 clamp(190px, calc(100cqw - 1610px), 310px); min-width: 0; }
.hm-sw-header__document { flex: 0 0 clamp(190px, calc(100cqw - 1610px - var(--hm-sw-header-leading-width, 0px)), 310px); min-width: 0; }
.hm-sw-header__field--layer { flex-shrink: 1; }
.hm-sw-header__field:not(.hm-sw-header__field--layer) { flex-shrink: 0; }
@@ -1732,12 +1734,12 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-header__fields > [data-tier="aux"] { display: none; }
.hm-sw-header__more { display: block; }
.hm-sw-header__more-item[data-tier="aux"] { display: flex; }
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 1030px), 310px); }
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 1030px - var(--hm-sw-header-leading-width, 0px)), 310px); }
}
@container hm-sw-header (max-width: 1179.98px) {
.hm-sw-header__mode { display: none; }
.hm-sw-header__fields { padding-left: 4px; }
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 944px), 310px); }
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 944px - var(--hm-sw-header-leading-width, 0px)), 310px); }
}
@container hm-sw-header (max-width: 1079.98px) {
.hm-sw-header__properties { display: none; }
@@ -1752,19 +1754,19 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-header__motion-tooltip { left: calc(100% + 1px); top: 11px; }
.hm-sw-header__motion:hover > .hm-sw-header__motion-tooltip { animation: none; opacity: 1; }
.hm-sw-header__window { flex-basis: 176px; width: 176px; }
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 725px), 310px); }
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 725px - var(--hm-sw-header-leading-width, 0px)), 310px); }
}
@container hm-sw-header (max-width: 899.98px) {
.hm-sw-header__fields > [data-menu="color"] { display: none; }
.hm-sw-header__more-item[data-tier="narrow"] { display: flex; }
.hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 529px), 310px); }
.hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 529px - var(--hm-sw-header-leading-width, 0px)), 310px); }
}
@container hm-sw-header (max-width: 639.98px) {
.hm-sw-header__actions,
.hm-sw-header__motion { display: none; }
.hm-sw-header__window { flex-basis: 64px; width: 64px; }
.hm-sw-header__more-item[data-tier="action"] { display: flex; }
.hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 417px), 310px); }
.hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 417px - var(--hm-sw-header-leading-width, 0px)), 310px); }
.hm-sw-header__document-button { padding-right: 8px; }
}
@container hm-sw-header (max-width: 519.98px) {
@@ -1840,3 +1842,37 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
@container hm-sw-header (max-width: 1079.98px) {
.hm-sw-header__window[data-reduced]:not(:has(.hm-sw-header__window-buttons:not([data-partial]))) .hm-sw-header__motion-tooltip { left: auto; right: calc(100% + 1px); }
}
/* SW Tool (SWToolButton / SWToolGroup, 2026-10-19): the existing toggle, pill and file-name looks reused as
public buttons. Only placement rules live here; every color, size and state comes from the rules above. */
.hm-sw-tool__group { margin-inline: 0; }
.hm-sw-tool__item { display: contents; }
.hm-sw-tool__group .hm-sw-header__toggle { padding-inline: 8px; }
.hm-sw-tool__icon { position: absolute; inset: 20.1% 20% 19.62%; display: grid; place-items: center; pointer-events: none; }
.hm-sw-tool__icon > :is(img, svg) { display: block; max-width: 100%; max-height: 100%; }
.hm-sw-tool__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* File-name look as a button (e.g. Open next to the logo): content width, never the file-name clamp. */
.hm-sw-header__document[data-tool] { flex: 0 0 auto; }
.hm-sw-header__document[data-tool] > .hm-sw-header__document-button { width: auto; }
/* Leading area right of the logo (SWHeader documentOpenPlacement="header" / leading, 2026-10-19). It is the
flex item the logo overlaps (like the file name), so the first file-name-look button keeps the Figma junction.
Its measured width is --hm-sw-header-leading-width, subtracted from the file-name clamp above. */
.hm-sw-header__leading { position: relative; z-index: 1; display: flex; flex: 0 0 auto; gap: 2px; height: var(--hm-sw-header-bar-height); }
/* The file name after an Open button no longer sits under the logo: 12px start padding (D-1), not 5px. */
:is(.hm-sw-header__leading, .hm-sw-header__document[data-tool]) + .hm-sw-header__document > .hm-sw-header__document-button { padding-left: 12px; }
/* While the header Open is shown, the default fields keep their width: a lack of room becomes a measured
overflow that moves Open into More instead of narrowing the layer field. Custom leading content alone keeps
the existing behavior (file name first, then the layer field below 520px). */
.hm-sw-header[data-leading="open"] .hm-sw-header__fields:not([data-configured]) > :is(.hm-sw-header__layer, .hm-sw-header__field--layer, .hm-sw-header__control[data-control="layer"]) { flex-shrink: 0; }
.hm-sw-header__more[data-forced] { display: block; }
/* Tool groups reuse the 상세보기 / print-K surfaces but are host tools: the Header breakpoints that move those
built-in parts into More never hide them (they have no More entry). */
.hm-sw-header__properties.hm-sw-tool__group { display: flex; }
.hm-sw-header__actions.hm-sw-tool__pill { display: flex; }
/* Configured layouts too: while the header Open is shown, controls other than the file name keep their width,
so a lack of room is a measured overflow that moves Open into More (the file name still gives way first). */
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control:not([data-control="document"]),
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control:not([data-control="document"]) > * { flex-shrink: 0; }
/* ...and the file name gives way only down to the default design's smallest width (96px, see the clamps above). */
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control[data-control="document"] { min-width: 96px; }
@@ -0,0 +1,98 @@
// Build reproducibility of @hm-design/sw (2026-10-19).
// Copies the current packages/sw source (+ root tsconfig.app.json it extends) into three directories
// outside the repository — two LF at different depths and one CRLF copy (as made outside git) — links
// the repository node_modules (same locked tools), builds each twice and packs it.
// dist must be byte-identical everywhere; the tarball must be identical for every LF checkout.
// Usage: node packages/sw/tests/build-reproducibility.mjs <empty-dir-outside-the-repo> <evidence-dir>
import { execFileSync } from "node:child_process"
import { createHash } from "node:crypto"
import { cp, mkdir, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
const repoRoot = path.resolve(packageRoot, "../..")
const outside = dir => { const relative = path.relative(repoRoot, dir); return relative.startsWith("..") || path.isAbsolute(relative) }
const work = path.resolve(process.argv[2] ?? "")
const evidence = path.resolve(process.argv[3] ?? "")
if (!process.argv[2] || !process.argv[3] || !outside(work)) throw new Error("Pass <empty dir outside the repo> <evidence dir>")
await mkdir(work, { recursive: true })
if ((await readdir(work)).length) throw new Error(`${work} is not empty`)
await mkdir(evidence, { recursive: true })
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim()
const env = { ...process.env, SW_BUILD_COMMIT: `${commit}+reproducibility-check` }
const sha = data => createHash("sha256").update(data).digest("hex")
// SVG assets are excluded: they are inlined as base64, so their bytes are fixed at checkout by
// packages/sw/.gitattributes (eol=lf) and checked separately with `git ls-files --eol`.
const textFile = name => /\.(tsx?|css|mjs|json|md)$/.test(name)
async function copySource(target, crlf) {
const sw = path.join(target, "packages/sw")
for (const entry of ["src", "ai", "README.md", "build.mjs", "package.json", "tsconfig.json", "tsconfig.build.json"]) {
await cp(path.join(packageRoot, entry), path.join(sw, entry), { recursive: true })
}
await cp(path.join(repoRoot, "tsconfig.app.json"), path.join(target, "tsconfig.app.json"))
if (crlf) {
const convert = async directory => {
for (const item of await readdir(directory, { withFileTypes: true })) {
const file = path.join(directory, item.name)
if (item.isDirectory()) await convert(file)
else if (textFile(item.name)) await writeFile(file, (await readFile(file, "utf8")).replace(/\r?\n/g, "\r\n"))
}
}
await convert(sw)
}
// Same locked tools: a directory junction to the repository node_modules (removed with rmdir semantics below).
await symlink(path.join(repoRoot, "node_modules"), path.join(target, "node_modules"), "junction")
return sw
}
async function hashes(sw) {
const out = {}
const walk = async directory => {
for (const item of await readdir(directory, { withFileTypes: true })) {
const file = path.join(directory, item.name)
if (item.isDirectory()) await walk(file)
else out[path.relative(sw, file).replaceAll("\\", "/")] = sha(await readFile(file))
}
}
await walk(path.join(sw, "dist"))
return out
}
const npm = process.platform === "win32" ? "npm.cmd" : "npm"
const runs = []
const layouts = [["lf", false], ["deeper/path/lf", false], ["crlf-copy", true]]
for (const [name, crlf] of layouts) {
const target = path.join(work, name)
const sw = await copySource(target, crlf)
for (const attempt of [1, 2]) {
execFileSync(process.execPath, ["build.mjs"], { cwd: sw, env, stdio: "pipe" })
const files = await hashes(sw)
const packDir = path.join(target, `pack-${attempt}`)
await mkdir(packDir)
execFileSync(npm, ["pack", "--ignore-scripts", "--pack-destination", packDir], { cwd: sw, env, stdio: "pipe", shell: process.platform === "win32" })
const tarball = (await readdir(packDir)).find(file => file.endsWith(".tgz"))
runs.push({ checkout: crlf ? "CRLF" : "LF", path: target, attempt, tarball, tarballSha256: sha(await readFile(path.join(packDir, tarball))), files })
}
}
// Leave the junctions behind as junctions only (never recurse into the repository node_modules).
for (const [name] of layouts) await rm(path.join(work, name, "node_modules"), { force: true })
const reference = runs[0]
const differences = runs.slice(1).map(run => ({
run: `${run.checkout} #${run.attempt}`,
tarballSame: run.tarballSha256 === reference.tarballSha256,
differentFiles: Object.keys({ ...reference.files, ...run.files }).filter(file => reference.files[file] !== run.files[file]),
}))
// Index and working tree of every package file must be LF (or binary) so a fresh checkout matches.
const eol = execFileSync("git", ["ls-files", "--eol", "--", "."], { cwd: packageRoot, encoding: "utf8" }).trim().split("\n")
const notLf = eol.filter(line => !/^i\/(lf|-text)\s+w\/(lf|-text)\s/.test(line))
// dist must match everywhere; the tarball also ships ai/ and README.md verbatim, so it must match for LF
// checkouts (guaranteed by .gitattributes). A CRLF copy made outside git only has to keep dist identical.
const pass = differences.every(item => item.differentFiles.length === 0 && (item.run.startsWith("CRLF") || item.tarballSame)) && notLf.length === 0
const report = { status: pass ? "PASS" : "FAIL", commit, notLf, buildCommit: env.SW_BUILD_COMMIT, runs: runs.map(({ files, ...run }) => ({ ...run, fileCount: Object.keys(files).length })), referenceFiles: reference.files, differences }
await writeFile(path.join(evidence, "build-reproducibility-result.json"), JSON.stringify(report, null, 2) + "\n")
console.log(report.status, JSON.stringify(differences))
if (!pass) process.exitCode = 1
@@ -0,0 +1,145 @@
// Browser check of the consumer built by integrated-consumer.mjs (2026-10-19): both stylesheet orders, examples
// E (custom popup) and F (Open + tool bar), HM UI inside an SW popup, Escape stack, computed-style identity.
// Usage: node packages/sw/tests/integrated-consumer.browser.mjs <consumer-dir> <evidence-dir>
import { createRequire } from "node:module"
import { createServer } from "node:http"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
const dist = path.resolve(process.argv[2], "dist")
const out = path.resolve(process.argv[3])
const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? "C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright")
await mkdir(out, { recursive: true })
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".ttf": "font/ttf" }
const server = createServer(async (request, response) => {
const pathname = decodeURIComponent(new URL(request.url, "http://x").pathname)
const file = path.join(dist, pathname.endsWith("/") ? `${pathname}index.html` : pathname)
let body
try { body = await readFile(file) } catch { response.writeHead(404); response.end(); return }
response.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" })
response.end(body)
}).listen(0, "127.0.0.1")
await new Promise(resolve => server.once("listening", resolve))
const base = `http://127.0.0.1:${server.address().port}/`
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" })
const report = { browser: browser.version(), checks: [], errors: [], styles: {} }
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? "PASS" : "FAIL", data }); console.log(ok ? "PASS" : "FAIL", name, ok ? "" : JSON.stringify(data ?? "")) }
// Computed styles of representative SW and UI elements; must not depend on the stylesheet order.
const snapshot = page => page.evaluate(() => {
const pick = (selector, props) => { const e = document.querySelector(selector); if (!e) return null; const s = getComputedStyle(e), r = e.getBoundingClientRect(); return { w: Math.round(r.width * 10) / 10, h: Math.round(r.height * 10) / 10, ...Object.fromEntries(props.map(p => [p, s.getPropertyValue(p)])) } }
const box = ["background-color", "background-image", "border-top", "border-radius", "box-shadow", "color", "font-family", "font-size", "font-weight", "padding", "margin"]
return {
header: pick('[data-example="a"] .hm-sw-header', box),
field: pick('[data-example="a"] .hm-sw-header__field--layer', box),
documentButton: pick('[data-example="a"] .hm-sw-header__document-button', box),
toggle: pick('[data-example="a"] .hm-sw-header__toggle', box),
open: pick('[data-example="f"] [data-document-open]', box),
toolText: pick('[data-example="f"] .hm-sw-tool__text-button', box),
toolPill: pick('[data-example="f"] .hm-sw-tool__pill-button', box),
popup: pick('[data-example="e"] .hm-sw-property', box),
popupTitle: pick('[data-example="e"] .hm-sw-property__title', box),
popupRow: pick('[data-example="e"] .hm-sw-property__row', box),
popupInput: pick('[data-example="e"] .hm-sw-property__value .hm-sw-header__input', box),
searchInput: pick('[data-example="d"] .hm-sw-header__input', box),
uiButton: pick('[data-example="ui"] [data-slot="button"]', box),
uiSwitch: pick('[data-example="ui"] [data-slot="switch"]', box),
uiSelect: pick('[data-example="ui"] [data-slot="select-trigger"]', box),
}
})
for (const mode of ["sw-first", "ui-first"]) {
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } })
page.on("pageerror", error => report.errors.push(`${mode}: ${error.message}`))
page.on("console", message => { if (message.type() === "error") report.errors.push(`${mode}: ${message.text()}`) })
const events = async () => (await page.locator("#events").textContent()).split("\n").filter(Boolean)
const last = async () => (await events()).at(-1)
const section = id => page.locator(`[data-example="${id}"]`)
await page.goto(`${base}${mode}/`)
await section("f").locator("header.hm-sw-header").waitFor()
await page.evaluate(() => document.fonts.ready)
await page.mouse.move(1, 1)
const assets = await page.evaluate(() => [...document.images].map(image => image.complete && image.naturalWidth > 0))
check(`${mode}: every SVG/PNG asset decodes`, assets.length > 0 && assets.every(Boolean), assets.length)
report.styles[mode] = await snapshot(page)
await page.screenshot({ path: path.join(out, `${mode}-1920.png`), fullPage: true })
// F: Open next to the logo, no + New, no freeze / lock, tool bar.
const f = section("f")
const open = f.locator("[data-document-open]")
check(`${mode} F: Open next to the logo with documentOpenLabel`, await open.textContent() === "파일 열기" && await open.evaluate(e => e.closest(".hm-sw-header__leading")?.previousElementSibling?.classList.contains("hm-sw-header__home") === true))
await open.click()
check(`${mode} F: Open calls onDocumentOpen`, await last() === "document:open", await last())
await f.locator(".hm-sw-header__control[data-control=\"document\"] .hm-sw-header__document-button").click()
check(`${mode} F: document dropdown without + New and Open (both hidden or placed)`, await f.locator(".hm-sw-header__document-row").count() === 2 && await f.locator(".hm-sw-header__document-footer").count() === 0)
await page.keyboard.press("Escape")
await f.locator(".hm-sw-header__field--layer").click()
check(`${mode} F: layer rows have no freeze / lock buttons`, await f.locator(".hm-sw-header__layer-toggle--freeze, .hm-sw-header__layer-toggle--lock").count() === 0 && await f.locator(".hm-sw-header__layer-row [data-empty]").count() === 8)
await page.keyboard.press("Escape")
await f.getByRole("radio", { name: "Model" }).focus()
await page.keyboard.press("ArrowRight")
check(`${mode} F: Model / Layout radio group by keyboard`, await last() === "view:layout" && await f.getByRole("radio", { name: "Layout" }).getAttribute("aria-checked") === "true", await last())
await f.getByRole("button", { name: "2.5D" }).click()
check(`${mode} F: 2.5D toggle`, await last() === "2.5d:true" && await f.getByRole("button", { name: "2.5D" }).getAttribute("aria-pressed") === "true")
await f.getByRole("button", { name: "화면 맞춤" }).click()
check(`${mode} F: Fit pill`, await last() === "fit")
await page.setViewportSize({ width: 640, height: 1080 })
await page.waitForTimeout(200)
const narrow = await f.locator("header.hm-sw-header").evaluate(h => ({ open: h.querySelectorAll("[data-document-open]").length, overflow: h.querySelector(".hm-sw-header__bar").scrollWidth - h.querySelector(".hm-sw-header__bar").clientWidth, height: h.getBoundingClientRect().height }))
await f.locator(".hm-sw-header__more-trigger").click()
const moreOpen = f.locator(".hm-sw-header__more-item", { hasText: "파일 열기" })
const inMore = await moreOpen.isVisible()
if (inMore) await moreOpen.click()
check(`${mode} F 640px: Open moved into More (measured), works there, no overflow`, narrow.open === 0 && inMore && await last() === "document:open" && narrow.overflow <= 1 && narrow.height === 49, { narrow, inMore })
await page.setViewportSize({ width: 1920, height: 1080 })
await page.waitForTimeout(200)
check(`${mode} F 1920px again: Open returns next to the logo`, await f.locator("[data-document-open]").count() === 1)
// E: custom popup.
const e = section("e")
const frame = await e.locator(".hm-sw-property").evaluate(el => { const s = getComputedStyle(el); return { w: el.getBoundingClientRect().width, bg: s.backgroundColor, border: s.borderTopColor } })
check(`${mode} E: SW popup frame (314, #E4DBC9, #A67D03)`, frame.w === 314 && frame.bg === "rgb(228, 219, 201)" && frame.border === "rgb(166, 125, 3)", frame)
const name = e.getByLabel("이름")
await name.click(); await name.fill("평면도-02"); await name.press("Enter")
check(`${mode} E: row input commits`, await last() === "name:평면도-02", await last())
await e.locator(".hm-sw-header__field--layer").click()
const menu = e.locator(".hm-sw-header__layer-menu")
check(`${mode} E: layer dropdown inside the popup is a fixed overlay`, await menu.evaluate(el => getComputedStyle(el).position) === "fixed")
await menu.locator(".hm-sw-header__layer-item", { hasText: "DIM" }).click()
check(`${mode} E: layer selection callback`, await last() === "layer:dim", await last())
// HM UI inside an SW popup.
const ui = section("ui")
await ui.getByRole("switch", { name: "스냅" }).click()
check(`${mode} UI in popup: Switch`, await last() === "ui:snap:false", await last())
await ui.getByRole("combobox", { name: "단위" }).click()
await page.getByRole("option", { name: "cm" }).click()
check(`${mode} UI in popup: Select (portal)`, await last() === "ui:unit:cm", await last())
await ui.getByRole("slider", { name: "투명도" }).focus()
await page.keyboard.press("ArrowRight")
await page.waitForTimeout(100)
check(`${mode} UI in popup: Slider`, /ui:opacity:70/.test(await last()), await last())
await ui.getByRole("button", { name: "적용" }).click()
check(`${mode} UI in popup: Button`, await last() === "ui:apply", await last())
// Escape stack: the UI popup was used last, so Escape outside closes it only; then the example E popup.
await page.mouse.click(1900, 1060)
await page.keyboard.press("Escape")
check(`${mode} Escape outside closes only the last used popup`, await last() === "ui:close" && await e.locator(".hm-sw-property").count() === 1, await last())
await e.getByLabel("X").click()
await page.keyboard.press("Escape")
check(`${mode} Escape inside a popup closes that popup`, await last() === "popup:close", await last())
await page.close()
}
const keys = Object.keys(report.styles["sw-first"])
const differences = keys.filter(key => JSON.stringify(report.styles["sw-first"][key]) !== JSON.stringify(report.styles["ui-first"][key]))
check("stylesheet order (SW first / UI first) changes no computed SW or UI style", differences.length === 0 && keys.every(key => report.styles["sw-first"][key] !== null), { differences, missing: keys.filter(key => report.styles["sw-first"][key] === null) })
check("no page/console errors", report.errors.length === 0, report.errors)
await writeFile(path.join(out, "integrated-consumer-result.json"), JSON.stringify(report, null, 2) + "\n")
await browser.close()
server.close()
const failed = report.checks.filter(item => item.status === "FAIL").length
console.log(`${report.checks.length - failed}/${report.checks.length} PASS`)
process.exitCode = failed ? 1 : 0
+118
View File
@@ -0,0 +1,118 @@
// Independent consumer of the 2026-10-19 SW integrated improvement: React 19 + TypeScript + Vite + Tailwind CSS 4
// with BOTH tarballs (@hm-design/sw and @hm-design/ui). Two builds load the same app with the stylesheets in both
// orders (SW first / UI first) to show that they do not change each other. The shipped ai/examples (A–F) are
// copied from node_modules, plus a popup holding HM UI components.
// Usage: node packages/sw/tests/integrated-consumer.mjs <empty-dir-outside-the-repo>
// Build first: npm run build -w @hm-design/sw && npm run build -w @hm-design/ui
import { execFileSync } from "node:child_process"
import { cp, mkdir, readdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
const repoRoot = path.resolve(packageRoot, "../..")
const out = path.resolve(process.argv[2] ?? "")
const relative = path.relative(repoRoot, out)
if (!process.argv[2] || !(relative.startsWith("..") || path.isAbsolute(relative))) throw new Error("Pass an empty directory outside the repository")
await mkdir(out, { recursive: true })
if ((await readdir(out)).length) throw new Error(`${out} is not empty`)
const npm = (args, cwd) => execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", args, { cwd, stdio: "inherit", shell: process.platform === "win32" })
npm(["pack", "--ignore-scripts", "--pack-destination", out], packageRoot)
npm(["pack", "--ignore-scripts", "--pack-destination", out], path.join(repoRoot, "packages/ui"))
const tarballs = (await readdir(out)).filter(name => name.endsWith(".tgz"))
const app = `import { useState } from "react"
import { SWHeaderInput, SWPopup, SWPopupRow, SWPopupSection, SWTheme } from "@hm-design/sw"
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Slider, Switch } from "@hm-design/ui"
import { ExampleADefaultHeader } from "./examples/ExampleA-DefaultHeader"
import { ExampleBViewerHeader } from "./examples/ExampleB-ViewerHeader"
import { ExampleCFeatureHeader } from "./examples/ExampleC-FeatureHeader"
import { ExampleDSearchExtension } from "./examples/ExampleD-SearchExtension"
import { ExampleECustomPopup } from "./examples/ExampleE-CustomPopup"
import { ExampleFViewerToolbar } from "./examples/ExampleF-ViewerToolbar"
const examples = { a: ExampleADefaultHeader, b: ExampleBViewerHeader, c: ExampleCFeatureHeader, d: ExampleDSearchExtension, e: ExampleECustomPopup, f: ExampleFViewerToolbar }
function UiPopup({ onEvent }: { onEvent: (name: string) => void }) {
const [snap, setSnap] = useState(true)
const [unit, setUnit] = useState("mm")
const [opacity, setOpacity] = useState([60])
return (
<SWTheme>
<SWPopup title="HM UI in SW Popup" onClose={() => onEvent("ui:close")}>
<SWPopupSection title="SW">
<SWPopupRow label="메모"><SWHeaderInput aria-label="메모" value="A" onCommit={next => onEvent("ui:memo:" + next)} /></SWPopupRow>
</SWPopupSection>
<SWPopupSection title="HM UI">
<SWPopupRow label="스냅" field="none"><Switch aria-label="스냅" checked={snap} onCheckedChange={next => { setSnap(next); onEvent("ui:snap:" + next) }} /></SWPopupRow>
<SWPopupRow label="단위" field="none">
<Select value={unit} onValueChange={next => { setUnit(next); onEvent("ui:unit:" + next) }}>
<SelectTrigger aria-label="단위"><SelectValue /></SelectTrigger>
<SelectContent>{["mm", "cm", "m"].map(item => <SelectItem key={item} value={item}>{item}</SelectItem>)}</SelectContent>
</Select>
</SWPopupRow>
<SWPopupRow label="투명도" field="none"><Slider aria-label="투명도" value={opacity} min={0} max={100} step={10} onValueChange={setOpacity} onValueCommit={next => onEvent("ui:opacity:" + next[0])} /></SWPopupRow>
</SWPopupSection>
<Button size="sm" onClick={() => onEvent("ui:apply")}>적용</Button>
</SWPopup>
</SWTheme>
)
}
export function App() {
const [events, setEvents] = useState<string[]>([])
const onEvent = (name: string) => setEvents(list => [...list, name])
return (
<>
{Object.entries(examples).map(([id, Example]) => (
<section key={id} data-example={id} style={{ marginBottom: 360 }}><Example onEvent={onEvent} /></section>
))}
<section data-example="ui" style={{ padding: 16, background: "#3a3a3a" }}><UiPopup onEvent={onEvent} /></section>
<pre id="events">{events.join("\\n")}</pre>
</>
)
}
`
// The bundler merges the CSS of pages that share modules, so the two stylesheet orders are two builds
// (--mode sw-first / ui-first): "app-order.css" is aliased to the stylesheet entry of that order.
const entry = `import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import "app-order.css"
import { App } from "./App"
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>)
`
const viteConfig = `import { fileURLToPath } from "node:url"
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
export default defineConfig(({ mode }) => ({
base: "./",
plugins: [react(), tailwindcss()],
resolve: { alias: { "app-order.css": fileURLToPath(new URL(mode === "ui-first" ? "./src/ui-first.css" : "./src/sw-first.css", import.meta.url)) } },
build: { outDir: mode === "ui-first" ? "dist/ui-first" : "dist/sw-first" },
}))
`
const ui = `@import "tailwindcss";\n@import "@hm-design/ui/tailwind.css";\n@import "@hm-design/ui/theme.css";\n`
const sw = `@import "@hm-design/sw/styles.css";\n`
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>SW consumer</title><link rel="icon" href="data:," /><style>body { margin: 0; }</style></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>\n`
const files = {
"package.json": JSON.stringify({ name: "sw-integrated-consumer", private: true, type: "module", scripts: { typecheck: "tsc -p tsconfig.json", build: "vite build --mode sw-first && vite build --mode ui-first" } }, null, 2),
"tsconfig.json": JSON.stringify({ compilerOptions: { target: "ES2022", lib: ["ES2022", "DOM", "DOM.Iterable"], module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: false, isolatedModules: true, noUnusedLocals: true, noUnusedParameters: true, types: ["vite/client"] }, include: ["src"] }, null, 2),
"vite.config.ts": viteConfig,
"index.html": html,
"src/sw-first.css": sw + ui,
"src/ui-first.css": ui + sw,
"src/App.tsx": app,
"src/main.tsx": entry,
}
for (const [name, text] of Object.entries(files)) {
await mkdir(path.dirname(path.join(out, name)), { recursive: true })
await writeFile(path.join(out, name), text)
}
npm(["install", "--no-audit", "--no-fund", ...tarballs.map(name => `./${name}`), "react@19.3.0", "react-dom@19.3.0", "tailwindcss@4.3.3"], out)
npm(["install", "--no-audit", "--no-fund", "-D", "typescript@7.0.2", "vite@8.3.0", "@vitejs/plugin-react@6.1.1", "@tailwindcss/vite@4.3.3", "@types/react@19.3.0", "@types/react-dom@19.3.0"], out)
await cp(path.join(out, "node_modules/@hm-design/sw/ai/examples"), path.join(out, "src/examples"), { recursive: true })
npm(["run", "typecheck"], out)
npm(["run", "build"], out)
console.log(`Consumer ready: ${out}. Next: node packages/sw/tests/integrated-consumer.browser.mjs ${out} <evidence-dir>`)
@@ -0,0 +1,202 @@
// Functional checks of the 2026-10-19 SW integrated improvement on the built Storybook (storybook-static).
// Usage: node packages/sw/tests/integrated-improvement.browser.mjs <evidence-dir> [section ...]
// Sections: options, tool, popup (default: all). Screenshots and result JSON go to <evidence-dir>.
import { createRequire } from 'node:module'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const out = path.resolve(process.argv[2] ?? 'docs/sw/integrated-improvement-qa/verify')
const sections = process.argv.slice(3).length ? process.argv.slice(3) : ['options', 'tool', 'popup']
await mkdir(out, { recursive: true })
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(root, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
const { chromium } = require('playwright')
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } })
const report = { browser: browser.version(), checks: [], errors: [], data: {} }
page.on('pageerror', e => report.errors.push(e.message))
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, ok || data === undefined ? '' : JSON.stringify(data)) }
const goto = async id => {
await page.goto(`${base}/iframe.html?id=${id}&viewMode=story`)
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
await page.evaluate(() => document.fonts.ready)
await page.mouse.move(1, 1)
await page.waitForTimeout(100)
}
const log = () => page.locator('[data-callback-log]').first().textContent()
const rect = locator => locator.evaluate(e => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height, right: r.right } })
if (sections.includes('options')) {
await goto('sw-header-options--open-placement')
const frames = page.locator('.sw-story-frame')
// Default placement unchanged: no header Open, dropdown footer keeps + New / Open.
const plain = frames.nth(0).locator('header.hm-sw-header')
check('menu (default): no Open next to the logo', await plain.locator('[data-document-open]').count() === 0)
await plain.locator('.hm-sw-header__document-button').click()
check('menu (default): dropdown footer keeps + New and Open', JSON.stringify(await plain.locator('.hm-sw-header__document-action').allTextContents()) === '["+ New","Open"]')
await page.keyboard.press('Escape')
const plainHome = await rect(plain.locator('.hm-sw-header__home')), plainFile = await rect(plain.locator('.hm-sw-header__document-button'))
const junction = plainFile.x - plainHome.right // the file name's place after the logo in the unchanged header
report.data.junction = junction
const header = page.locator('[data-open-placement="header"] header.hm-sw-header')
const open = header.locator('[data-document-open]')
const home = await rect(header.locator('.hm-sw-header__home')), openBox = await rect(open), docBox = await rect(header.locator('.hm-sw-header__bar > .hm-sw-header__document .hm-sw-header__document-button'))
check('header: Open takes the file name place right of the logo (same junction offset, 48px)', Math.abs(openBox.x - home.right - junction) < 0.6 && openBox.h === 48, { home, openBox, junction })
check('header: file name follows Open without overlap', docBox.x >= openBox.right - 0.5, { openBox, docBox })
const styles = await open.evaluate(e => { const s = getComputedStyle(e); return { cls: e.className, bg: s.backgroundImage.slice(0, 40), color: s.color, weight: s.fontWeight, padL: s.paddingLeft, borderL: s.borderLeftWidth, borderR: s.borderRightWidth } })
const fileStyles = await header.locator('.hm-sw-header__bar > .hm-sw-header__document .hm-sw-header__document-button').evaluate(e => { const s = getComputedStyle(e); return { bg: s.backgroundImage.slice(0, 40), color: s.color, weight: s.fontWeight, padL: s.paddingLeft } })
check('header: Open uses hm-sw-header__document-button (same surface, color, weight as the file name)', styles.cls.includes('hm-sw-header__document-button') && styles.bg === fileStyles.bg && styles.color === fileStyles.color && styles.weight === fileStyles.weight && styles.padL === '5px', { styles, fileStyles })
check('D-1: file name after Open starts with 12px padding', fileStyles.padL === '12px', fileStyles)
await open.hover()
check('header: Open hover turns only the text #FFC600', await open.locator('.hm-sw-header__document-name').evaluate(e => getComputedStyle(e).color) === 'rgb(255, 198, 0)')
await page.mouse.move(1, 1)
await page.locator('[data-open-placement="header"]').screenshot({ path: path.join(out, 'open-placement-header.png') })
await header.locator('.hm-sw-header__bar > .hm-sw-header__document .hm-sw-header__document-button').click()
check('header: dropdown footer has + New only (no duplicate Open)', JSON.stringify(await header.locator('.hm-sw-header__document-action').allTextContents()) === '["+ New"]')
await page.keyboard.press('Escape')
await open.click()
check('header: Open calls onDocumentOpen', (await log()).includes('onDocumentOpen'))
check('documentOpenLabel is the Open text', await frames.nth(2).locator('[data-document-open]').textContent() === '파일 열기')
// Responsive: measured room. No overflow anywhere; Open is either shown or reachable in More.
const widths = []
for (const item of await page.locator('[data-responsive-open] > [data-width]').all()) {
const width = Number(await item.getAttribute('data-width'))
const h = item.locator('header.hm-sw-header')
const state = await h.evaluate(header => {
const bar = header.querySelector(':scope > .hm-sw-header__bar'), area = header.querySelector('.hm-sw-header__object-area')
const open = header.querySelector('[data-document-open]'), doc = header.querySelector('.hm-sw-header__bar > .hm-sw-header__document')
const layer = header.querySelector('.hm-sw-header__field--layer')
const parts = [...bar.children].filter(e => getComputedStyle(e).display !== 'none').map(e => e.getBoundingClientRect())
const overlap = parts.some((r, i) => i > 1 && r.left < parts[i - 1].right - 0.5)
return {
height: header.getBoundingClientRect().height, barOverflow: bar.scrollWidth - bar.clientWidth, areaOverflow: area.scrollWidth - area.clientWidth,
headerRight: header.getBoundingClientRect().right, barRight: Math.max(...parts.map(r => r.right)), overlap,
open: open ? open.getBoundingClientRect().width : 0, doc: doc ? doc.getBoundingClientRect().width : 0, layer: layer ? layer.getBoundingClientRect().width : 0,
more: !!header.querySelector('.hm-sw-header__more') && getComputedStyle(header.querySelector('.hm-sw-header__more')).display !== 'none',
}
})
let inMore = null
if (!state.open) {
await h.locator('.hm-sw-header__more-trigger').click()
const itemOpen = h.locator('.hm-sw-header__more-item', { hasText: /^Open$/ })
inMore = await itemOpen.isVisible()
if (inMore) { await itemOpen.click(); inMore = (await log()).endsWith('onDocumentOpen') } else await page.keyboard.press('Escape')
}
widths.push({ width, ...state, inMore })
check(`responsive ${width}: 49px, no overflow or overlap, nothing past the edge`, state.height === 49 && state.barOverflow <= 1 && state.areaOverflow <= 1 && !state.overlap && state.barRight <= state.headerRight + 0.5, state)
check(`responsive ${width}: Open ${state.open ? 'shown next to the logo' : 'moved into More and works there'}`, state.open > 0 || inMore === true, { open: state.open, inMore })
if (width >= 520) check(`responsive ${width}: layer field keeps its Figma width (234) while Open is shown`, !state.open || Math.abs(state.layer - 234) < 0.6 || state.layer === 0, state.layer)
}
report.data.responsive = widths
await page.locator('[data-responsive-open]').screenshot({ path: path.join(out, 'open-placement-responsive.png') })
// Hysteresis: shrink a header below the collapse width, then grow it back; Open returns, never flickers in between.
const probe = page.locator('[data-responsive-open] > [data-width="520"] .sw-story-frame')
const shown = async () => probe.locator('[data-document-open]').count()
const trace = []
for (const w of [520, 560, 600, 640, 680, 720, 760, 820, 900, 1080]) { await probe.evaluate((e, w) => { e.style.width = `${w}px` }, w); await page.waitForTimeout(80); trace.push([w, await shown()]) }
report.data.hysteresis = trace
const flips = trace.slice(1).filter((step, index) => step[1] !== trace[index][1]).length
check('resize: collapsed Open returns once when the header grows (measured, with hysteresis)', trace[0][1] === 0 && trace.at(-1)[1] === 1 && flips === 1, trace)
await goto('sw-header-options--hidden-sub-controls')
const onlyOpen = page.locator('[data-hidden="new"] .hm-sw-header__document-footer')
const footer = await rect(onlyOpen), action = await rect(onlyOpen.locator('.hm-sw-header__document-action'))
check('hidden documentNew: footer keeps Open on its right side', JSON.stringify(await onlyOpen.locator('.hm-sw-header__document-action').allTextContents()) === '["Open"]' && Math.abs(footer.right - 8 - action.right) < 1, { footer, action })
check('hidden documentNew + documentOpen: no footer', await page.locator('[data-hidden="new-open"] .hm-sw-header__document-footer').count() === 0 && await page.locator('[data-hidden="new-open"] .hm-sw-header__document-row').count() === 2)
const layer = page.locator('[data-hidden="layer-states"]')
const icons = await layer.locator('.hm-sw-header__field--layer .hm-sw-header__layer-icons > *').evaluateAll(list => list.map(e => ({ empty: e.hasAttribute('data-empty'), w: e.getBoundingClientRect().width })))
check('hidden layerFreeze / layerLock: field draws the bulb only, two empty 20px places', icons.length === 3 && !icons[0].empty && icons[1].empty && icons[2].empty && icons.every(i => i.w === 20), icons)
const rows = await layer.locator('.hm-sw-header__layer-row').evaluateAll(list => list.map(row => ({ buttons: [...row.querySelectorAll('button')].map(b => b.className.split(' ').at(-1)), empty: row.querySelectorAll('[data-empty]').length, name: row.querySelector('.hm-sw-header__layer-name').getBoundingClientRect().x })))
check('hidden layerFreeze / layerLock: rows have no snowflake / lock button, places kept', rows.every(r => r.empty === 2 && !r.buttons.some(b => /freeze|lock/.test(b))), rows)
await layer.locator('.hm-sw-header__layer-toggle--visibility').first().click()
check('hidden layer states: the bulb still reports its callback', (await log()).includes('onLayerVisibilityChange(0, false)'))
await page.locator('[data-hidden="layer-states"]').screenshot({ path: path.join(out, 'hidden-layer-states.png') })
await page.locator('[data-hidden="new"]').screenshot({ path: path.join(out, 'hidden-document-new.png') })
await goto('sw-header-options--composed-open')
const bar = page.locator('[data-composed-open]')
const cHome = await rect(bar.locator('.hm-sw-header__home')), cOpen = await rect(bar.locator('.hm-sw-header__document[data-tool] button')), cDoc = bar.locator('.hm-sw-header__document:not([data-tool]) .hm-sw-header__document-button')
check('composed: Open right of the logo at the same junction offset as SWHeader', Math.abs(cOpen.x - cHome.right - report.data.junction) < 0.6 && cOpen.h === 48, { cHome, cOpen })
check('composed: file name after Open has 12px padding', await cDoc.evaluate(e => getComputedStyle(e).paddingLeft) === '12px')
await bar.locator('.hm-sw-header__document[data-tool] button').click()
check('composed: Open callback', (await log()).includes('onDocumentOpen'))
await cDoc.click()
check('composed: showOpen={false} removes Open from the dropdown', JSON.stringify(await bar.locator('.hm-sw-header__document-action').allTextContents()) === '["+ New"]')
await page.keyboard.press('Escape')
await bar.screenshot({ path: path.join(out, 'composed-open.png') })
}
if (sections.includes('options') || sections.includes('composed')) {
await goto('sw-header-options--composed-responsive')
const rows = []
for (const item of await page.locator('[data-composed-responsive] > [data-width]').all()) {
const width = Number(await item.getAttribute('data-width'))
const h = item.locator('header.hm-sw-header')
const state = await h.evaluate(header => {
const bar = header.querySelector(':scope > .hm-sw-header__bar'), area = header.querySelector('.hm-sw-header__object-area')
const visible = selector => [...header.querySelectorAll(selector)].filter(e => e.getClientRects().length && getComputedStyle(e).visibility !== 'hidden').length
const clipped = [...header.querySelectorAll('.hm-sw-header__control:not([data-hidden]) .hm-sw-header__field')].some(f => f.getBoundingClientRect().width < 40)
return { height: header.getBoundingClientRect().height, barOverflow: bar.scrollWidth - bar.clientWidth, areaOverflow: area.scrollWidth - area.clientWidth, open: visible('[data-document-open]'), more: visible('.hm-sw-header__more-trigger'), clipped, right: Math.max(...[...bar.children].map(e => e.getBoundingClientRect().right)) - header.getBoundingClientRect().right }
})
rows.push({ width, ...state })
check(`composed responsive ${width}: 49px, no overflow, nothing past the edge`, state.height === 49 && state.barOverflow <= 1 && state.areaOverflow <= 1 && state.right <= 0.5, state)
if (!state.open) {
await h.locator('.hm-sw-header__more-trigger').click()
const entry = h.locator('.hm-sw-header__more-item', { hasText: '파일 열기' })
const ok = await entry.isVisible()
if (ok) await entry.click(); else await page.keyboard.press('Escape')
check(`composed responsive ${width}: 파일 열기 reachable in More`, ok && (await log()).endsWith('onDocumentOpen'))
}
}
report.data.composedResponsive = rows
await page.locator('[data-composed-responsive]').screenshot({ path: path.join(out, 'composed-responsive.png') })
}
if (sections.includes('tool')) {
await goto('sw-tool--tool-buttons')
const group = page.locator('[data-view-group]').first()
check('tool: Model / Layout is a radio group', await group.getAttribute('role') === 'radiogroup' && await group.locator('[role="radio"]').count() === 2)
await group.locator('[role="radio"]').first().focus()
await page.keyboard.press('ArrowRight')
check('tool: arrow key moves focus and selects Layout', await group.locator('[role="radio"]').nth(1).getAttribute('aria-checked') === 'true' && (await log()).includes('view(layout)'))
const depth = page.locator('[data-display-group] button').first()
await depth.click()
check('tool: 2.5D toggle reports and shows pressed (#FFC600)', await depth.getAttribute('aria-pressed') === 'true' && await depth.evaluate(e => getComputedStyle(e).color) === 'rgb(255, 198, 0)')
const text = await page.locator('[data-tool-states] .hm-sw-header__toggle').first().evaluate(e => { const s = getComputedStyle(e); return { weight: s.fontWeight, h: e.getBoundingClientRect().height } })
const ref = await page.locator('[data-display-group]').first().evaluate(e => getComputedStyle(e.querySelector('.hm-sw-header__properties-caption')).fontSize)
check('tool: text buttons are the 속성Bar toggle look (700, 19px row, 12px caption)', text.weight === '700' && text.h === 19 && ref === '12px', { text, ref })
const pill = page.locator('[data-fit-group] button').first()
const pillBox = await rect(pill)
await pill.hover()
const hover = await pill.evaluate(e => ({ ring: getComputedStyle(e, '::after').opacity, icon: getComputedStyle(e.querySelector('.hm-sw-header__icon')).visibility, hoverIcon: getComputedStyle(e.querySelector('.hm-sw-header__icon-hover')).visibility }))
check('tool: pill button is 40 x 38 with the yellow ring and hover icon', pillBox.w === 40 && pillBox.h === 38 && hover.ring === '1' && hover.icon === 'hidden' && hover.hoverIcon === 'visible', { pillBox, hover })
await page.mouse.move(1, 1)
await pill.click()
check('tool: pill click callback', (await log()).includes('fit'))
check('tool: pill accessible name from its text', await pill.evaluate(e => e.textContent.trim()) === '화면 맞춤')
const toolBar = page.locator('[data-tool-bar]')
const tHome = await rect(toolBar.locator('.hm-sw-header__home')), tOpen = await rect(toolBar.locator('.hm-sw-header__document[data-tool] button'))
check('tool: document look keeps the logo junction in a composed bar', Math.abs(tOpen.x - tHome.right - (report.data.junction ?? -15)) < 0.6, { tHome, tOpen })
for (const [name, locator] of [['tool-in-header', page.locator('.sb-page-section').nth(0)], ['tool-composed', page.locator('.sb-page-section').nth(1)], ['tool-states', page.locator('.sb-page-section').nth(2)]]) await locator.screenshot({ path: path.join(out, `${name}.png`) })
}
if (sections.includes('popup')) {
const { popupChecks } = await import('./integrated-popup.checks.mjs')
await popupChecks({ page, goto, check, log, rect, out, report })
}
check('no browser console or page errors', report.errors.length === 0, report.errors)
const failed = report.checks.filter(c => c.status === 'FAIL').length
report.status = failed ? 'FAIL' : 'PASS'
await writeFile(path.join(out, 'integrated-improvement-result.json'), JSON.stringify(report, null, 2) + '\n')
console.log(`TOTAL ${report.checks.length} FAIL ${failed}`)
await browser.close(); server.close()
if (failed) process.exitCode = 1
@@ -0,0 +1,92 @@
// SW/Popup checks for integrated-improvement.browser.mjs (2026-10-19): frame identity, free content (SW + HM UI),
// callbacks, dropdown clipping, Escape / focus with several popups, scrolling.
import path from 'node:path'
export async function popupChecks({ page, goto, check, log, rect, out, report }) {
await goto('sw-popup--custom-content')
const frames = page.locator('[data-popups] .hm-sw-property')
const frame = await frames.evaluateAll(list => list.map(e => { const s = getComputedStyle(e), t = getComputedStyle(e.querySelector('.hm-sw-property__title')); return { w: e.getBoundingClientRect().width, bg: s.backgroundColor, border: s.borderTopColor, radius: s.borderRadius, shadow: s.boxShadow, title: t.color, weight: t.fontWeight, titlebar: e.querySelector('.hm-sw-property__titlebar').getBoundingClientRect().height, role: e.getAttribute('role'), modal: e.getAttribute('aria-modal') } }))
report.data.popupFrames = frame
const same = frame.every(f => JSON.stringify(f) === JSON.stringify(frame[2]))
check('popup: SWPopup frames match the property window frame (314, surface, border, radius, title)', frame.length === 3 && same && frame[0].w === 314 && frame[0].bg === 'rgb(228, 219, 201)' && frame[0].border === 'rgb(166, 125, 3)' && frame[0].radius === '4px' && frame[0].title === 'rgb(255, 198, 0)' && frame[0].titlebar === 30 && frame[0].role === 'dialog' && frame[0].modal === 'false', frame)
const settings = page.locator('[data-popup="settings"]')
const commit = async (locator, value) => { await locator.click(); await locator.fill(value); await locator.press('Enter') }
await commit(settings.getByLabel('이름'), '평면도-02')
await commit(settings.getByLabel('축척'), '50')
check('popup content: SWHeaderInput rows commit through the host callbacks', (await log()).includes('name=평면도-02') && (await log()).includes('scale=50'))
const rowStyle = await settings.locator('.hm-sw-property__row').first().evaluate(e => ({ label: getComputedStyle(e.querySelector('label')).width, minH: getComputedStyle(e).minHeight, box: getComputedStyle(e.querySelector('.hm-sw-property__value')).height }))
check('popup content: SWPopupRow is the property row (72px label, 32px row, 28px box)', rowStyle.label === '72px' && rowStyle.minH === '32px' && rowStyle.box === '28px', rowStyle)
await settings.locator('[data-ui-switch]').click()
check('popup content: HM UI Switch works inside', (await log()).includes('snap=false'))
await settings.locator('[data-ui-select]').click()
await page.getByRole('option', { name: 'cm' }).click()
check('popup content: HM UI Select (portal) works inside', (await log()).includes('unit=cm'))
await settings.locator('[data-ui-slider] [role="slider"]').focus()
await page.keyboard.press('ArrowRight')
await page.waitForTimeout(100)
check('popup content: HM UI Slider works inside', /opacity=70/.test(await log()), await log())
await settings.locator('[data-ui-apply]').click()
check('popup content: HM UI Button works inside', (await log()).includes('apply'))
const layerField = settings.locator('[data-popup-layer] button.hm-sw-header__field')
await layerField.click()
const fieldBox = await rect(layerField), menu = settings.locator('.hm-sw-header__layer-menu')
const menuBox = await rect(menu)
const hit = await menu.locator('.hm-sw-header__layer-row').last().evaluate(row => { const r = row.getBoundingClientRect(); const e = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); return row.contains(e) })
check('popup dropdown: SW layer dropdown opens right under its field (fixed overlay) and is not clipped', await menu.evaluate(e => getComputedStyle(e).position) === 'fixed' && Math.abs(menuBox.y - (fieldBox.y + fieldBox.h)) < 1 && Math.abs(menuBox.x - fieldBox.x) < 1 && hit, { fieldBox, menuBox, hit })
await page.locator('[data-popups]').screenshot({ path: path.join(out, 'popup-custom-dropdown.png') })
await menu.locator('.hm-sw-header__layer-item', { hasText: 'DIM' }).click()
check('popup dropdown: selection callback, list closes, popup stays', (await log()).includes('layer=dim') && await settings.locator('.hm-sw-header__layer-menu').count() === 0 && await settings.isVisible())
await settings.locator('[data-popup-color] button.hm-sw-header__field').click()
const colorPanel = settings.locator('.hm-sw-header__menu-panel')
check('popup dropdown: SW color dropdown becomes a fixed overlay', await colorPanel.evaluate(e => getComputedStyle(e).position) === 'fixed')
await colorPanel.getByLabel('초록').click()
check('popup dropdown: color chip callback', (await log()).includes('color=green'))
await page.keyboard.press('Escape')
check('popup dropdown: Escape closes the dropdown only', await settings.locator('.hm-sw-header__menu-panel').count() === 0 && await settings.isVisible())
const object = page.locator('[data-popup="object"]')
check('object popup: read-only rows', await object.getByLabel('이름').evaluate(e => e.readOnly) && await object.getByLabel('길이').evaluate(e => e.readOnly))
await commit(object.getByLabel('X'), '1500')
check('object popup: number row commits', (await log()).includes('x=1500'))
const compact = await object.getByLabel('Y').evaluate(e => ({ cls: e.className, w: e.getBoundingClientRect().width }))
check('object popup: compact row uses the 41px hug field', compact.cls.includes('hm-sw-property__compact') && compact.w < 60, compact)
const panel = page.locator('[data-popups] .hm-sw-property').nth(2)
const order = await panel.locator('.hm-sw-property__body > *').evaluateAll(list => list.map(e => e.getAttribute('aria-label')))
check('property panel + children: children follow the property groups in the same body', JSON.stringify(order) === '["공통","메모"]', order)
await commit(panel.getByLabel('설명'), '내벽')
check('property panel + children: child content callback', (await log()).includes('memo=내벽'))
await settings.locator('.hm-sw-property__close').click()
check('popup: close button calls onClose', (await log()).includes('settings.onClose'))
await page.locator('[data-popups]').screenshot({ path: path.join(out, 'popup-custom-content.png') })
await goto('sw-popup--multiple-popups')
const popup = id => page.locator(`[data-popup="${id}"]`)
const b = popup('b')
await b.locator('[data-popup-layer] button.hm-sw-header__field').click()
const bMenu = b.locator('.hm-sw-header__layer-menu'), bHit = await bMenu.locator('.hm-sw-header__layer-footer').evaluate(row => { const r = row.getBoundingClientRect(); return row.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) })
const bBox = await rect(b), menuB = await rect(bMenu)
check('short popup: dropdown extends past the popup and stays fully visible (no clipping)', menuB.y + menuB.h > bBox.y + bBox.h && bHit, { bBox, menuB, bHit })
await page.locator('[data-stack]').screenshot({ path: path.join(out, 'popup-short-dropdown.png') })
await page.keyboard.press('Escape')
check('several popups: Escape in a dropdown closes only the dropdown', await bMenu.count() === 0 && await popup('a').count() === 1 && await b.count() === 1 && await popup('c').count() === 1)
const aInput = popup('a').getByLabel('A 값')
await aInput.click(); await aInput.fill('A2'); await aInput.press('Escape')
check('several popups: Escape on an edited input cancels the edit only', await aInput.inputValue() === 'A' && await popup('a').count() === 1)
await aInput.press('Escape')
check('several popups: Escape inside a popup closes that popup only', (await log()).endsWith('close(a)') && await b.count() === 1 && await popup('c').count() === 1, await log())
await page.locator('body').click({ position: { x: 5, y: 5 } })
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()
await popup('a').locator('.hm-sw-property__titlebar').click()
await page.locator('body').click({ position: { x: 5, y: 5 } })
await page.keyboard.press('Escape')
check('several popups: the popup used last becomes the top', (await log()).endsWith('close(a)'), await log())
const c = popup('c').locator('.hm-sw-property__body')
const scroll = await c.evaluate(e => ({ sh: e.scrollHeight, ch: e.clientHeight, overflow: getComputedStyle(e).overflowY }))
await c.evaluate(e => { e.scrollTop = e.scrollHeight })
const lastVisible = await popup('c').getByLabel('필드 30').evaluate(e => { const r = e.getBoundingClientRect(); return e.closest('.hm-sw-property__row').contains(document.elementFromPoint(r.x + 4, r.y + r.height / 2)) })
check('long content: the body scrolls inside the frame and the last row is reachable', scroll.sh > scroll.ch && scroll.overflow === 'auto' && lastVisible && (await rect(popup('c'))).h === 360, { scroll, lastVisible })
await page.locator('[data-stack]').screenshot({ path: path.join(out, 'popup-stack.png') })
}
@@ -0,0 +1,71 @@
// Visual preservation for the SW integrated improvement (2026-10-19).
// capture: screenshots every existing SW Story (1920) and the Header Stories at 10 widths from storybook-static.
// compare: pixel-diffs two capture directories and writes compare-result.json.
// Usage: node packages/sw/tests/integrated-visual.browser.mjs capture <dir>
// node packages/sw/tests/integrated-visual.browser.mjs compare <before-dir> <after-dir> <out-dir>
import { createRequire } from 'node:module'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const [mode, a, b, c] = process.argv.slice(2)
// Stories that existed before the improvement; IDs are fixed so new Stories never enter the comparison.
export const baselineStories = ['overview', 'detail-buttons', 'interactive-states', 'window-move-motion', 'logo-slot', 'document-dropdown', 'responsive-more', 'k-guide', 'property-panel-compare', 'composed-controls']
const widthStories = ['overview', 'responsive-more']
const widths = [1920, 1800, 1440, 1180, 1080, 900, 768, 640, 520, 390]
if (mode === 'capture') {
const out = path.resolve(a)
await mkdir(out, { recursive: true })
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(root, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
const { chromium } = require('playwright')
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 }, reducedMotion: 'reduce' })
const errors = []
page.on('pageerror', e => errors.push(e.message))
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
const shots = []
const shoot = async (id, width) => {
await page.setViewportSize({ width, height: 1000 })
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story`)
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
await page.evaluate(() => document.fonts.ready)
await page.mouse.move(1, 1)
await page.addStyleTag({ content: '*,*::before,*::after{animation-play-state:paused!important;animation-delay:0s!important;transition:none!important;caret-color:transparent!important}' })
await page.waitForTimeout(150)
const file = `${id}-${width}.png`
await page.screenshot({ path: path.join(out, file), fullPage: true })
shots.push(file)
}
for (const id of baselineStories) await shoot(id, 1920)
for (const id of widthStories) for (const width of widths.slice(1)) await shoot(id, width)
await writeFile(path.join(out, 'capture.json'), JSON.stringify({ browser: browser.version(), shots, errors }, null, 2))
await browser.close(); server.close()
console.log(`captured ${shots.length}, errors ${errors.length}`)
} else if (mode === 'compare') {
const { PNG } = require('pngjs')
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
const before = path.resolve(a), after = path.resolve(b), out = path.resolve(c)
await mkdir(out, { recursive: true })
const results = []
for (const file of (await readdir(before)).filter(name => name.endsWith('.png'))) {
let next
try { next = PNG.sync.read(await readFile(path.join(after, file))) } catch { results.push({ file, status: 'MISSING' }); continue }
const prev = PNG.sync.read(await readFile(path.join(before, file)))
if (prev.width !== next.width || prev.height !== next.height) { results.push({ file, status: 'SIZE', before: [prev.width, prev.height], after: [next.width, next.height] }); continue }
const diff = new PNG({ width: prev.width, height: prev.height })
const changed = pixelmatch(prev.data, next.data, diff.data, prev.width, prev.height, { threshold: 0 })
if (changed) await writeFile(path.join(out, `diff-${file}`), PNG.sync.write(diff))
results.push({ file, status: changed ? 'DIFF' : 'SAME', changed })
}
const summary = { total: results.length, same: results.filter(r => r.status === 'SAME').length, results }
await writeFile(path.join(out, 'compare-result.json'), JSON.stringify(summary, null, 2))
console.log(`${summary.same}/${summary.total} identical`)
for (const r of results.filter(r => r.status !== 'SAME')) console.log(r.status, r.file, r.changed ?? '')
} else throw new Error('mode: capture | compare')
+4 -2
View File
@@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process'
import path from 'node:path'
import http from 'node:http'
import { parse } from '@babel/parser'
import { swStoryEntries, retainedInventory, inventorySignature, retiredDropdownIds } from './storybook-inventory.mjs'
import { swStoryEntries, retainedInventory, inventorySignature, retiredDropdownIds, integratedChangedFiles } from './storybook-inventory.mjs'
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-overview-qa')
await mkdir(out, { recursive: true })
@@ -24,7 +24,9 @@ const oldStories = stories(baseline), newStories = stories(source)
const retiredExports = ['LayerDropdown','ColorDropdown','LineStyleDropdown','TextDimensionStyleDropdown','ComposedControls']
check('retained non-Overview declarations byte-identical; relocated composition separately verified', Object.keys(oldStories).filter(k => k !== 'Overview' && !retiredExports.includes(k)).every(k => oldStories[k] === newStories[k]))
check('retired Dropdown IDs absent', retiredDropdownIds.every(id => !after.some(e => e.id === id)))
check('package production source untouched', !execFileSync('git', ['diff', report.baseCommit, '--', 'packages/sw/src'], { encoding:'utf8' }))
// Since 2026-10-19 the production source changes only in the integrated-improvement files (their own suites verify them).
const sourceDiff = execFileSync('git', ['diff', '--name-only', report.baseCommit, '--', 'packages/sw/src'], { encoding:'utf8' }).trim().split(/\r?\n/).filter(Boolean)
check('package production source untouched except the integrated-improvement files', sourceDiff.every(file => integratedChangedFiles.includes(file)), sourceDiff.filter(file => !integratedChangedFiles.includes(file)))
const root = path.resolve('storybook-static'), mime = { '.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.svg':'image/svg+xml','.ttf':'font/ttf','.png':'image/png' }
const server = http.createServer(async(req,res) => {
+10
View File
@@ -10,3 +10,13 @@ export const swStoryEntries = index => Object.values(index.entries).filter(entry
export const retainedInventory = entries => entries.filter(entry => ![...retiredStage1Ids, ...retiredDropdownIds].includes(entry.id))
.map(entry => entry.id === 'sw-header--composed-controls' ? { ...entry, name: 'Dropdown', title: 'SW/Header' } : entry)
export const inventorySignature = entries => entries.map(entry => [entry.id, entry.name, entry.tags]).sort((a, b) => a[0].localeCompare(b[0]))
// 2026-10-19 SW integrated improvement (0.2.0): the only Stories added after the unused-demo removal, under their
// own titles (SW/Header keeps its 10 Stories), and the only files of the package it changed.
export const integratedStoryIds = ['sw-header-options--open-placement', 'sw-header-options--hidden-sub-controls', 'sw-header-options--composed-open', 'sw-header-options--composed-responsive', 'sw-tool--tool-buttons', 'sw-popup--custom-content', 'sw-popup--multiple-popups']
export const integratedChangedFiles = [
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
'packages/sw/src/index.ts', 'packages/sw/src/theme/sw.css',
'packages/sw/src/components/sw-header/SWHeader.tsx', 'packages/sw/src/components/sw-header/index.ts', 'packages/sw/src/components/sw-header/types.ts',
...['SWHeaderDocumentTitle', 'SWHeaderDropdown', 'SWHeaderFields', 'SWHeaderLayerMenu', 'SWHeaderLineStyleMenu', 'SWHeaderMore', 'SWHeaderPropertyFields', 'SWHeaderPropertyPanel'].map(name => `packages/sw/src/components/sw-header/components/${name}.tsx`),
]
@@ -8,7 +8,7 @@ import { execFileSync } from 'node:child_process'
import path from 'node:path'
import http from 'node:http'
import { parse } from '@babel/parser'
import { swStoryEntries, retiredUnusedDemoIds } from './storybook-inventory.mjs'
import { swStoryEntries, retiredUnusedDemoIds, integratedStoryIds, integratedChangedFiles } from './storybook-inventory.mjs'
const out = path.resolve(process.argv[2] ?? 'docs/sw/storybook-unused-story-removal-qa/verify')
const qa = 'docs/sw/storybook-unused-story-removal-qa/before'
@@ -26,10 +26,12 @@ const beforeSW = swStoryEntries(beforeIndex), afterSW = swStoryEntries(afterInde
const expected = beforeSW.filter(e => !retiredUnusedDemoIds.includes(e.id))
report.data.inventory = { before: [stories(beforeIndex).length, beforeSW.length], after: [stories(afterIndex).length, afterSW.length], sw: afterSW.map(e => [e.id, e.name]) }
await writeFile(path.join(out, 'story-inventory-after.json'), JSON.stringify({ total: stories(afterIndex).length, sw: afterSW.map(e => ({ id: e.id, name: e.name, title: e.title, tags: e.tags })) }, null, 2) + '\n')
check('inventory: 19 -> 10 SW Stories, 338 -> 329 total', beforeSW.length === 19 && afterSW.length === 10 && stories(beforeIndex).length === 338 && stories(afterIndex).length === 329, report.data.inventory)
// 2026-10-19 integrated improvement: 329 + its 7 Stories under SW/Header/Options, SW/Tool and SW/Popup (SW/Header stays 10).
const added = stories(afterIndex).filter(e => !beforeIndex.entries[e.id])
check('inventory: 19 -> 10 SW/Header Stories, 338 -> 329 total plus only the 7 integrated-improvement Stories', beforeSW.length === 19 && afterSW.length === 10 && stories(beforeIndex).length === 338 && stories(afterIndex).length === 329 + integratedStoryIds.length && JSON.stringify(added.map(e => e.id).sort()) === JSON.stringify([...integratedStoryIds].sort()), { ...report.data.inventory, added: added.map(e => e.id) })
check('inventory: the nine requested IDs existed before and are absent now', retiredUnusedDemoIds.length === 9 && retiredUnusedDemoIds.every(id => beforeIndex.entries[id] && !afterIndex.entries[id]))
check('inventory: retained SW IDs, names, titles, tags and order unchanged', sig(expected) === sig(afterSW), afterSW.map(e => e.id))
const others = index => Object.values(index.entries).filter(e => !retiredUnusedDemoIds.includes(e.id))
const others = index => Object.values(index.entries).filter(e => !retiredUnusedDemoIds.includes(e.id) && !integratedStoryIds.includes(e.id) && !/^sw-(header-options|tool|popup)--docs$/.test(e.id))
check('inventory: every non-removed index entry identical (docs and other packages included)', JSON.stringify(others(beforeIndex)) === JSON.stringify(others(afterIndex)))
check('inventory: Dropdown keeps sw-header--composed-controls / Dropdown / SW/Header', afterIndex.entries['sw-header--composed-controls']?.name === 'Dropdown' && afterIndex.entries['sw-header--composed-controls']?.title === 'SW/Header')
check('inventory: comparison keeps its ID and name', afterIndex.entries['sw-header--property-panel-compare']?.name === 'Property Panel · 4개 비교')
@@ -44,9 +46,12 @@ const removedExports = ['PropertyFields', 'PropertyShapeSlot', 'Composition', 'C
check('source: the nine Story exports are removed', removedExports.every(name => oldExports[name] && !newExports[name]))
const changed = Object.keys(oldExports).filter(name => !removedExports.includes(name) && oldExports[name] !== newExports[name])
check('source: retained exports (Stories and ComposedDemo) byte-identical', changed.length === 0 && Object.keys(newExports).every(name => oldExports[name]), changed)
// The SW package may change only in the files of the 2026-10-19 integrated improvement (verified by its own suites);
// other packages, Overview, Dropdown CSF, assets and Storybook config stay untouched.
const untouched = ['packages/sw/src', 'packages/sw/ai', 'packages/sw/package.json', 'packages/sw/README.md', 'packages/ui', 'packages/org-chart', 'packages/org-chart-react', 'packages/center-graphic', 'src/sw/sw-header-overview.tsx', 'src/sw/sw-header-dropdown.stories.tsx', 'src/sw/sw-composed-controls.css', 'src/sw/assets', '.storybook']
const diff = execFileSync('git', ['diff', '--name-only', baseCommit, '--', ...untouched], { encoding: 'utf8' }).trim()
check('source: runtime, public API, AI template, other packages, Overview, Dropdown CSF, assets and Storybook config untouched', diff === '', diff)
const diff = execFileSync('git', ['diff', '--name-only', baseCommit, '--', ...untouched], { encoding: 'utf8' }).trim().split(/\r?\n/).filter(Boolean)
const unexpected = diff.filter(file => !integratedChangedFiles.includes(file))
check('source: other packages, Overview, Dropdown CSF, assets and Storybook config untouched; SW package only within the integrated-improvement files', unexpected.length === 0, { unexpected, integrated: diff.length })
// ---- Browser
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.gif': 'image/gif', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }