Merge fix/sw-design-review-2026-10-21: SW design review and responsive header without More from 760px

This commit is contained in:
shkim4 committed 2026-10-21 17:29:50 +09:00
commit c1a69a4948
38 files changed
+454 -211

No files matched your search

+4 -1
View File
@@ -366,6 +366,8 @@ import { SWHeaderInput, parseSWHeaderNumber, SWTheme } from "@hm-design/sw"
### 반응형 More — 대표 필드 전환 ### 반응형 More — 대표 필드 전환
> **2026-10-21 (사용자 결정):** 헤더 폭 760px 이상에서는 More를 쓰지 않습니다. 5개 메뉴는 로고 옆 Open 유무와 관계없이 항상 Header에 남고, 좁아질수록 간격 축소 → 파일명 `…` → 정보값 `…` 순으로 줄어듭니다. 메뉴 타이틀은 모든 폭에서 표시하며 공간이 부족하면 `…`로 줄어듭니다. 아래 내용(대표 필드 전환·실측 More)은 760px 미만에만 적용됩니다. 상세는 [DESIGN_RULES](../../packages/sw/ai/DESIGN_RULES.md)의 반응형 항목.
헤더가 좁아 필드가 더보기(…)로 옮겨졌을 때, 더보기에서 필드 항목을 고르면 그 필드가 객체 수정 영역 첫 칸(레이어 필드 자리)에 표시되고 Dropdown이 바로 아래에 열립니다. Focus는 그 필드로 옮겨갑니다. 헤더가 좁아 필드가 더보기(…)로 옮겨졌을 때, 더보기에서 필드 항목을 고르면 그 필드가 객체 수정 영역 첫 칸(레이어 필드 자리)에 표시되고 Dropdown이 바로 아래에 열립니다. Focus는 그 필드로 옮겨갑니다.
- 레이어는 그동안 더보기로 이동하며, 더보기 → 레이어로 원래 배치로 돌아갑니다. 이미 화면에 있는 필드는 더보기에 나오지 않습니다. - 레이어는 그동안 더보기로 이동하며, 더보기 → 레이어로 원래 배치로 돌아갑니다. 이미 화면에 있는 필드는 더보기에 나오지 않습니다.
@@ -502,7 +504,7 @@ function DrawingHeader() {
- Uncontrolled: `propertyPanelOpen`을 생략합니다. Controlled: `propertyPanelOpen={open}`, `onPropertyPanelOpenChange={setOpen}`을 함께 전달합니다. 기존 `onPropertyPanelToggle`은 버튼/More 클릭 시 그대로 호출됩니다. 이전 Controlled 호스트가 새 Open Callback 없이 기존 Toggle Callback만 사용한다면 X/Escape 닫기에서도 기존 Toggle로 알립니다. - Uncontrolled: `propertyPanelOpen`을 생략합니다. Controlled: `propertyPanelOpen={open}`, `onPropertyPanelOpenChange={setOpen}`을 함께 전달합니다. 기존 `onPropertyPanelToggle`은 버튼/More 클릭 시 그대로 호출됩니다. 이전 Controlled 호스트가 새 Open Callback 없이 기존 Toggle Callback만 사용한다면 X/Escape 닫기에서도 기존 Toggle로 알립니다.
- 선택 대상이 바뀔 때 `selection.id`도 반드시 변경합니다. 같은 property id라도 이전 객체의 임시 편집값은 재사용하지 않습니다. - 선택 대상이 바뀔 때 `selection.id`도 반드시 변경합니다. 같은 property id라도 이전 객체의 임시 편집값은 재사용하지 않습니다.
- `selection.kind`: none/all/single. `selection.title`·`counts`·`overviewTitle`은 호스트 데이터입니다. counts의 선택적 row/column은 Figma 집계 배치를 표현할 수 있습니다. `onOverviewClick`도 선택 변경을 요청할 뿐 내부 CAD 동작은 없습니다. - `selection.kind`: none/all/single. `selection.title`·`counts`·`overviewTitle`은 호스트 데이터입니다. counts의 선택적 row/column은 Figma 집계 배치를 표현할 수 있습니다. `onOverviewClick`도 선택 변경을 요청할 뿐 내부 CAD 동작은 없습니다. 2026-10-21: 전체 선택 하단의 객체 유형·개수 목록은 표시 전용이며, 각 항목에 마우스를 올리면 그 항목 글자만 `#FF5C00`으로 바뀝니다(클릭·선택·필터 동작 없음).
- 항목 타입: text/number/select/color/slider/computed. `id/name/value/type/groupId`가 필수이고, `order`로 표시 순서를 지정합니다. 중복 id를 사용하지 마세요. - 항목 타입: text/number/select/color/slider/computed. `id/name/value/type/groupId`가 필수이고, `order`로 표시 순서를 지정합니다. 중복 id를 사용하지 마세요.
- `options`는 select의 id/name/disabled 목록입니다. 선택은 Enter 또는 Focus 이동으로 확정하고 Escape로 취소합니다. 삭제되거나 disabled가 된 option은 새 값으로 확정하지 않습니다. 옵션 데이터가 바뀌면 임시 선택을 현재 호스트 값으로 돌립니다. - `options`는 select의 id/name/disabled 목록입니다. 선택은 Enter 또는 Focus 이동으로 확정하고 Escape로 취소합니다. 삭제되거나 disabled가 된 option은 새 값으로 확정하지 않습니다. 옵션 데이터가 바뀌면 임시 선택을 현재 호스트 값으로 돌립니다.
- `color`는 기존 RGB/HEX parser를 재사용합니다. `swatch`로 별도 견본을 제공할 수 있습니다. `linePreview/linePattern`은 기존 선 미리보기를 재사용합니다. `annotation`은 (ByL) 같은 보조 정보, `unit`은 mm/% 같은 단위입니다. - `color`는 기존 RGB/HEX parser를 재사용합니다. `swatch`로 별도 견본을 제공할 수 있습니다. `linePreview/linePattern`은 기존 선 미리보기를 재사용합니다. `annotation`은 (ByL) 같은 보조 정보, `unit`은 mm/% 같은 단위입니다.
@@ -515,6 +517,7 @@ function DrawingHeader() {
- 동적 패널 입력은 `SWHeaderInput commitOnUnmount={false}`를 사용합니다. 기존 Input의 기본 `true` 동작은 유지합니다. - 동적 패널 입력은 `SWHeaderInput commitOnUnmount={false}`를 사용합니다. 기존 Input의 기본 `true` 동작은 유지합니다.
- 속성 필드 상태(2026-10-19, Figma 26:12388 ~ 26:12649): 공통 행은 200 × 28 필드(선가중치처럼 단위 없는 숫자와 투명도 값은 41px 내외 Hug)입니다. Default는 값만, Hover는 `rgba(33,29,19,.4)` 테두리, 숫자 편집 On은 `#0F0D0A` + 흰 단위, Dropdown On은 `#211D13`입니다. 하이퍼링크(text)·재료(computed)는 Figma에 On이 없어 Default/Hover만 적용합니다. [검증 기록](./sw-property-fields-verification-2026-10-19.md). - 속성 필드 상태(2026-10-19, Figma 26:12388 ~ 26:12649): 공통 행은 200 × 28 필드(선가중치처럼 단위 없는 숫자와 투명도 값은 41px 내외 Hug)입니다. Default는 값만, Hover는 `rgba(33,29,19,.4)` 테두리, 숫자 편집 On은 `#0F0D0A` + 흰 단위, Dropdown On은 `#211D13`입니다. 하이퍼링크(text)·재료(computed)는 Figma에 On이 없어 Default/Hover만 적용합니다. [검증 기록](./sw-property-fields-verification-2026-10-19.md).
- 필드 Dropdown: `property.picker`(`"layer" | "lineStyle" | "color" | false`)와 패널 `menus`(`SWHeaderPropertyMenus`: layers·toggle Callback, lineTypes·lineWeights·lineScale, colors·colorMode·onColorPick 등)를 전달합니다. `type: "color"`는 색상, `type: "select"` + `linePreview`는 선스타일 Dropdown이 기본이며 레이어는 `picker: "layer"`를 지정합니다. 목록 선택은 해당 속성의 `onPropertyChange`(값 = 행 id)로 확정되고, 레이어 Toggle·가중치·스케일·ByLayer/ByBlock·색상 추출·상세는 `menus` Callback만 호출합니다. `menus`가 없으면 `options`로 목록을 만듭니다. `mixed`는 `다양함`으로 표시하며 현재 행을 표시하지 않습니다. - 필드 Dropdown: `property.picker`(`"layer" | "lineStyle" | "color" | false`)와 패널 `menus`(`SWHeaderPropertyMenus`: layers·toggle Callback, lineTypes·lineWeights·lineScale, colors·colorMode·onColorPick 등)를 전달합니다. `type: "color"`는 색상, `type: "select"` + `linePreview`는 선스타일 Dropdown이 기본이며 레이어는 `picker: "layer"`를 지정합니다. 목록 선택은 해당 속성의 `onPropertyChange`(값 = 행 id)로 확정되고, 레이어 Toggle·가중치·스케일·ByLayer/ByBlock·색상 추출·상세는 `menus` Callback만 호출합니다. `menus`가 없으면 `options`로 목록을 만듭니다. `mixed`는 `다양함`으로 표시하며 현재 행을 표시하지 않습니다.
- 2026-10-21 디자인 검토: 속성창 선꼴 Dropdown은 선 종류 목록 1열만 표시합니다(`menus.lineWeights`·`lineScale`·가중치/스케일 Callback은 속성창 Dropdown에서 쓰이지 않으며 SWHeader 선스타일 필드는 3열 유지). 숫자 편집 Input은 값 영역 전체 너비를 채우고, 투명도는 값 + `annotation`이 하나의 편집 상자입니다. 수정 불가능한 값(readOnly·computed·Disabled)은 속성창에서 검정, 속성Bar에서 Bar 편집이 없는 값은 포커스 없는 텍스트(`<output>`)입니다. 상세는 `packages/sw/ai/DESIGN_RULES.md` 속성값 색상 항목.
- `decimals`를 지정한 숫자는 확정값을 고정 소수 자릿수로 정규화합니다(예: 선축적 `2` → `2.0000`). 색상 입력은 기존 0~255 검증을 유지합니다. - `decimals`를 지정한 숫자는 확정값을 고정 소수 자릿수로 정규화합니다(예: 선축적 `2` → `2.0000`). 색상 입력은 기존 0~255 검증을 유지합니다.
- K 가이드는 별도 데이터를 요구하지 않습니다. 기존 K 버튼 Hover/Focus로 원본 이미지가 Pan/Zoom 반복되고, 작은 화면의 More에서도 같은 미리보기가 보입니다. Reduced Motion·Escape·재진입 초기화가 적용됩니다. 클릭은 기존 `onPopupGuide`만 호출합니다. - K 가이드는 별도 데이터를 요구하지 않습니다. 기존 K 버튼 Hover/Focus로 원본 이미지가 Pan/Zoom 반복되고, 작은 화면의 More에서도 같은 미리보기가 보입니다. Reduced Motion·Escape·재진입 초기화가 적용됩니다. 클릭은 기존 `onPopupGuide`만 호출합니다.
- 색상 모드의 null은 직접 색상 의미를 보존하고, 필수 모드 표시는 마지막 유효 모드(처음 ByLayer)를 유지합니다. 선택된 모드를 재클릭해도 onColorModeChange가 중복 호출되지 않습니다. - 색상 모드의 null은 직접 색상 의미를 보존하고, 필수 모드 표시는 마지막 유효 모드(처음 ByLayer)를 유지합니다. 선택된 모드를 재클릭해도 onColorModeChange가 중복 호출되지 않습니다.
+5 -4
View File
@@ -34,9 +34,10 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
| 기타 | `activeMenu`, `disabled`(창 버튼 제외), `drawingModeLabel`, `tools?: ReactNode`(객체 영역 메뉴 뒤), `<header>` HTML 속성·`ref` | | 기타 | `activeMenu`, `disabled`(창 버튼 제외), `drawingModeLabel`, `tools?: ReactNode`(객체 영역 메뉴 뒤), `<header>` HTML 속성·`ref` |
- 데이터 배열(`layers`, `colors`, `lineTypes`/`lineWeights`, `textStyles`, `dimensionStyles`, `documents`)을 주지 않으면 해당 필드는 Dropdown 없는 버튼입니다. - 데이터 배열(`layers`, `colors`, `lineTypes`/`lineWeights`, `textStyles`, `dimensionStyles`, `documents`)을 주지 않으면 해당 필드는 Dropdown 없는 버튼입니다.
- 반응형(2026-10-21): Header 폭 760px 이상에서는 More로 옮기지 않습니다. 레이어·색상·선스타일·문자스타일·치수스타일 5개 메뉴와 타이틀은 항상 보이고, 좁아지면 그리기모드 텍스트·선/치수 그림 숨김 → 파일명 축소(최소 96px, `…`) → 메뉴 값 `…` 순으로 줄입니다. 1080px 미만(1024 등)은 아이콘·색상 칩·폰트명을 숨긴 텍스트만, 900px 이하는 선스타일 필드에서 가중치·스케일을 숨깁니다. 모든 값과 타이틀이 잘리지 않는 폭은 약 1,200px 이상입니다. 760px 미만은 기존 More 동작을 유지합니다.
- 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`). 조합형 구성이 반응형이어야 하면 `controls` + `documentOpenPlacement` + `leading` + `tools`로 `SWHeader` 안에서 구성합니다(예제 F). - 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`). 조합형 구성이 반응형이어야 하면 `controls` + `documentOpenPlacement` + `leading` + `tools`로 `SWHeader` 안에서 구성합니다(예제 F).
- 로고 옆 Open은 실제 공간을 측정해 넘칠 때만 More로 이동하고, 그 시점보다 Open 폭만큼 넓어지면 돌아옵니다. Open이 보이는 동안 파일명이 먼저 줄고 레이어 필드는 Figma 폭을 유지합니다. - (760px 미만) 로고 옆 Open은 실제 공간을 측정해 넘칠 때만 More로 이동하고, 그 시점보다 Open 폭만큼 넓어지면 돌아옵니다. Open이 보이는 동안 파일명이 먼저 줄고 레이어 필드는 Figma 폭을 유지합니다.
- 메뉴도 고정 폭만이 아니라 실제 남은 공간을 기준으로 More에 옮기고 공간이 생기면 원래 자리·순서로 돌려놓습니다(Breakpoint는 그대로, 추가 Prop 없음). 메뉴를 열고 닫아도 Header 요소의 위치·폭은 바뀌지 않습니다. - (760px 미만) 메뉴도 고정 폭만이 아니라 실제 남은 공간을 기준으로 More에 옮기고 공간이 생기면 원래 자리·순서로 돌려놓습니다(Breakpoint는 그대로, 추가 Prop 없음). 메뉴를 열고 닫아도 Header 요소의 위치·폭은 바뀌지 않습니다.
```tsx ```tsx
<SWHeader documentTitle={name} logoSrc={logo} logoAlt="제품명" layers={layers} activeLayerId={id} onLayerSelect={l => setId(l.id)} onClose={closeWindow} /> <SWHeader documentTitle={name} logoSrc={logo} logoAlt="제품명" layers={layers} activeLayerId={id} onLayerSelect={l => setId(l.id)} onClose={closeWindow} />
``` ```
@@ -104,7 +105,7 @@ Figma 26:10591(home on), 타일 15종 26:11355 … 26:11631, 섹션 제목 26:12
| `SWHomeMenu` | 2단 메뉴 전체(섹션 여러 개). 보통 `SWHeader homeMenu`로 씀 | `sections`(`SWHomeMenuSectionData[]`) 또는 `children`, `open`/`defaultOpen`(false)/`onOpenChange(open, reason)`, `activeIds`/`defaultActiveIds`/`onActiveChange`, `selection`(`multiple` 기본 / `single`), `onItemSelect`, `closeOnBackgroundClick`(true), `closeOnSelect`(false), `closeOnEscape`(true), `closeOnOutsideClick`(false), `autoFocus`(true), `returnFocusRef`, `accent`(`auto`/`sections`/`outline`), `moreLabel`("더보기") | 높이 77(섹션 75 + 강조 2), 섹션 폭은 타일 수로 자동. 좁으면 들어가는 섹션만 표시하고 나머지는 더보기(⋯) Dropdown의 텍스트 메뉴(섹션 제목 + 항목, 같은 Active·Callback·하위 패널)로 이동(가로 스크롤 없음, 넓어지면 복귀) | ✅ | | `SWHomeMenu` | 2단 메뉴 전체(섹션 여러 개). 보통 `SWHeader homeMenu`로 씀 | `sections`(`SWHomeMenuSectionData[]`) 또는 `children`, `open`/`defaultOpen`(false)/`onOpenChange(open, reason)`, `activeIds`/`defaultActiveIds`/`onActiveChange`, `selection`(`multiple` 기본 / `single`), `onItemSelect`, `closeOnBackgroundClick`(true), `closeOnSelect`(false), `closeOnEscape`(true), `closeOnOutsideClick`(false), `autoFocus`(true), `returnFocusRef`, `accent`(`auto`/`sections`/`outline`), `moreLabel`("더보기") | 높이 77(섹션 75 + 강조 2), 섹션 폭은 타일 수로 자동. 좁으면 들어가는 섹션만 표시하고 나머지는 더보기(⋯) Dropdown의 텍스트 메뉴(섹션 제목 + 항목, 같은 Active·Callback·하위 패널)로 이동(가로 스크롤 없음, 넓어지면 복귀) | ✅ |
| `SWHomeMenuSection` | 섹션(제목 badge + 타일 그룹) | `title`, `children` | badge 폭 = 글자 + 46px | ✅ | | `SWHomeMenuSection` | 섹션(제목 badge + 타일 그룹) | `title`, `children` | badge 폭 = 글자 + 46px | ✅ |
| `SWHomeMenuItem` | 80 × 46 타일(Default / Hover / Active) | 데이터 `SWHomeMenuItemData`: `id`, `label`, `icon`, `hoverIcon`, `toggle`(기본: `panel` 있는 항목만 true = 패널이 열린 동안 Active, 나머지는 실행 버튼 = 클릭 후 ON 없음. true = 켜고 끄기 유지), `panel`, `panelLabel`, `disabled`, `hidden`, `title`, `onSelect`. 메뉴 밖 단독이면 `active`/`defaultActive`/`onActiveChange` | Hover·Active = 주황 gradient 1px 테두리 + `#FF5C00` 아이콘·글자 | ✅ | | `SWHomeMenuItem` | 80 × 46 타일(Default / Hover / Active) | 데이터 `SWHomeMenuItemData`: `id`, `label`, `icon`, `hoverIcon`, `toggle`(기본: `panel` 있는 항목만 true = 패널이 열린 동안 Active, 나머지는 실행 버튼 = 클릭 후 ON 없음. true = 켜고 끄기 유지), `panel`, `panelLabel`, `disabled`, `hidden`, `title`, `onSelect`. 메뉴 밖 단독이면 `active`/`defaultActive`/`onActiveChange` | Hover·Active = 주황 gradient 1px 테두리 + `#FF5C00` 아이콘·글자 | ✅ |
| `SWRecentFiles` | 선택 하위 패널: 최근파일 목록 + 2뎁스 미리보기 | `files`(`SWFileEntry[]`: `id`, `name`, `path`, `badge`, `preview`, `previewTitle`, `disabled`), `onFileOpen`, `onFileHover`, `renderPreview`, `closeOnOpen`(true), `emptyText` | 폭 354, 행 50, 미리보기 310 × 270 | ✅ | | `SWRecentFiles` | 선택 하위 패널: 최근파일 목록(2026-10-21부터 앞 3개만 표시. Hover·Focus 시 2뎁스 미리보기 영역(제목 = 해당 파일의 `previewTitle` 또는 `name` + 검정 빈 본문)만 표시하고 `preview`·`renderPreview` 이미지는 호환용으로 받기만 하고 렌더링하지 않음) | `files`(`SWFileEntry[]`: `id`, `name`, `path`, `badge`, `preview`, `previewTitle`, `disabled`), `onFileOpen`, `onFileHover`, `renderPreview`, `closeOnOpen`(true), `emptyText` | 폭 354, 행 50, 미리보기 310 × 270 | ✅ |
- **Header 연결:** `SWHeader`의 `homeMenu`에 `SWHomeMenu` Props를 주면 됩니다. 기본 숨김, 로고 클릭 열기, 다시 클릭 닫기. 위치는 SWHeader가 실제 DOM으로 정합니다(객체 수정 영역 −2px ~ 창 영역, 고정 x 없음. `controls`에 `document`가 있으면 파일명 오른쪽 −2px부터). 로고·파일명·창 영역은 가리지 않습니다. - **Header 연결:** `SWHeader`의 `homeMenu`에 `SWHomeMenu` Props를 주면 됩니다. 기본 숨김, 로고 클릭 열기, 다시 클릭 닫기. 위치는 SWHeader가 실제 DOM으로 정합니다(객체 수정 영역 −2px ~ 창 영역, 고정 x 없음. `controls`에 `document`가 있으면 파일명 오른쪽 −2px부터). 로고·파일명·창 영역은 가리지 않습니다.
- **이벤트 우선순위:** `homeMenu`가 있으면 로고 클릭은 메뉴 열기·닫기만 하고 `onHomeClick`을 호출하지 않습니다(의도하지 않은 홈 이동 방지). 상태 알림은 `onHomeMenuOpenChange`(`reason`: `home`, `escape`, `background`, `outside`, `select`, `file`, `document`). - **이벤트 우선순위:** `homeMenu`가 있으면 로고 클릭은 메뉴 열기·닫기만 하고 `onHomeClick`을 호출하지 않습니다(의도하지 않은 홈 이동 방지). 상태 알림은 `onHomeMenuOpenChange`(`reason`: `home`, `escape`, `background`, `outside`, `select`, `file`, `document`).
@@ -167,7 +168,7 @@ SW **뷰어 모드** 전용 Export입니다(2026-10-19 추가, 참고 DWG/DXF
| `SWFooterAuxGroup` | 보조기능 토글 격자(Figma 3 × 2) | `items: SWFooterAuxItem[]`(필수), `onPressedChange`, `onItemClick`, `columns`(3), `label` | ◻ | | `SWFooterAuxGroup` | 보조기능 토글 격자(Figma 3 × 2) | `items: SWFooterAuxItem[]`(필수), `onPressedChange`, `onItemClick`, `columns`(3), `label` | ◻ |
| `SWFooterAuxButton` | 보조기능 104 × 33 버튼(Hover 노랑 테두리, `pressed` = 주황 테두리·아이콘·글자) | `label`(필수), `icon`(`currentColor` 아이콘이면 상태 색 적용), `pressed`, `button` 속성 | ◻ | | `SWFooterAuxButton` | 보조기능 104 × 33 버튼(Hover 노랑 테두리, `pressed` = 주황 테두리·아이콘·글자) | `label`(필수), `icon`(`currentColor` 아이콘이면 상태 색 적용), `pressed`, `button` 속성 | ◻ |
| `SWFooterToolButton` | 명령 칸 68 × 47 | `label`(필수), `icon`, `activeIcon`, `pressed`, `button` 속성 | ◻ | | `SWFooterToolButton` | 명령 칸 68 × 47 | `label`(필수), `icon`, `activeIcon`, `pressed`, `button` 속성 | ◻ |
| `SWFooterAddSlot` | 빈 칸(+), Hover 가이드 | `guide`(기본 Figma / `false`), `guideOpen`, `onGuideOpenChange`, `button` 속성 | ◻ | | `SWFooterAddSlot` | 빈 칸(+), Hover 가이드 | `guide`(기본 `false` = 없음, `{}` = Figma 가이드), `guideOpen`, `onGuideOpenChange`, `button` 속성 | ◻ |
| `SWFooterGuide` | 빈 칸 안내 카드 208 × 149 | `title`(`명령어 등록`), `caption`(`빠른사용`), `media`, `div` 속성 | ◻ | | `SWFooterGuide` | 빈 칸 안내 카드 208 × 149 | `title`(`명령어 등록`), `caption`(`빠른사용`), `media`, `div` 속성 | ◻ |
| `SWFooterMenu` | 분류 행(현재 탭 Pocket, 넘치면 ◀ ▶) | `items: SWFooterMenuItem[]`·`value`(필수), `onValueChange`, `onAdd`, `addLabel`, `label`, `panelId`, `tabIdPrefix` | ◻ | | `SWFooterMenu` | 분류 행(현재 탭 Pocket, 넘치면 ◀ ▶) | `items: SWFooterMenuItem[]`·`value`(필수), `onValueChange`, `onAdd`, `addLabel`, `label`, `panelId`, `tabIdPrefix` | ◻ |
| `SWFooterLayoutTabs` | 도면 공간 탭(+ · Layout · Model) | `layouts: SWFooterLayout[]`·`value`(필수), `onValueChange`, `onAdd`, `onRemove`, `onRename`, `editingId`/`onEditingChange`, `modelLabel`(`"Model"` / `false`), `label`, `addLabel`, `removeLabel` | ◻ | | `SWFooterLayoutTabs` | 도면 공간 탭(+ · Layout · Model) | `layouts: SWFooterLayout[]`·`value`(필수), `onValueChange`, `onAdd`, `onRemove`, `onRename`, `editingId`/`onEditingChange`, `modelLabel`(`"Model"` / `false`), `label`, `addLabel`, `removeLabel` | ◻ |
+6 -2
View File
@@ -82,7 +82,9 @@
## 7. 반응형 (`SWHeader` 전용) ## 7. 반응형 (`SWHeader` 전용)
Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타일 → More, **< 1180** 그리기모드 숨김, **< 1080** 상세보기 → More·창 이동 24px(창 영역 176), **< 900** 색상 → More, **< 640** 인쇄·K → More·창 이동 숨김(창 영역 64), **< 520** 레이어 상태 아이콘 숨김. More에서 고른 필드는 첫 칸(레이어 자리)에 표시되고 Dropdown이 바로 열립니다(UI 상태만, 데이터 Callback 없음). **760px 이상(2026-10-21 사용자 결정):** More 없이 5개 메뉴(레이어·색상·선스타일·문자스타일·치수스타일)를 항상 Header에 둡니다. 로고 옆 Open 유무와 관계없이 넘치지 않으며, 좁아질수록 **< 1800** 그리기모드 글자·선/치수 그림 숨김·간격 축소 → 파일명 축소(최소 96px, `…`) → 정보값 `…` 순으로 공간을 줄입니다. 메뉴 타이틀은 모든 폭에서 표시하며 공간이 부족하면 `…`로 줄어듭니다. **< 1080** 레이어 상태 아이콘·색상 칩·글꼴 이름 숨김, **≤ 900** 선스타일은 선스타일 값만. Dropdown 열기·닫기는 그대로입니다. 아래 Breakpoint·실측 More는 **760px 미만**에만 적용됩니다.
760px 미만 Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타일 → More, **< 1180** 그리기모드 숨김, **< 1080** 상세보기 → More·창 이동 24px(창 영역 176), **< 900** 색상 → More, **< 640** 인쇄·K → More·창 이동 숨김(창 영역 64), **< 520** 레이어 상태 아이콘 숨김. More에서 고른 필드는 첫 칸(레이어 자리)에 표시되고 Dropdown이 바로 열립니다(UI 상태만, 데이터 Callback 없음).
실측 More(2026-10-19): Breakpoint 위에서 객체 수정 영역의 실제 남은 공간을 측정해, `leading`·`tools`·긴 라벨 등으로 표시할 수 없는 메뉴만 같은 단계 순서(선/문자/치수 → 그리기모드 → 속성 블록 → 색상 → 구성형의 인쇄·K, 같은 단계는 오른쪽부터)로 More에 옮기고 공간이 생기면 역순으로 돌려놓습니다. 로고 옆 Open이 가장 먼저 옮겨집니다(좁아질 때). 넓어질 때(2026-10-20 수정안 A): Breakpoint로 막 돌아온 부분(인쇄·K, 색상, 속성 블록, 선/문자/치수, 그리기모드)은 실제 남은 공간이 있을 때만 돌아오고 없으면 More에 남으며, 다시 나타난 Open은 넓어지는 동안 다른 부분 때문에 More로 돌아가지 않습니다. 충분히 넓으면 모두 돌아옵니다. 메뉴 열기·닫기는 Header 요소의 위치·폭을 바꾸지 않습니다. 호스트 요소(`leading`·`tools`)는 More로 옮기지 않으므로 폭이 좁은 화면에는 많이 넣지 않습니다. 실측 More(2026-10-19): Breakpoint 위에서 객체 수정 영역의 실제 남은 공간을 측정해, `leading`·`tools`·긴 라벨 등으로 표시할 수 없는 메뉴만 같은 단계 순서(선/문자/치수 → 그리기모드 → 속성 블록 → 색상 → 구성형의 인쇄·K, 같은 단계는 오른쪽부터)로 More에 옮기고 공간이 생기면 역순으로 돌려놓습니다. 로고 옆 Open이 가장 먼저 옮겨집니다(좁아질 때). 넓어질 때(2026-10-20 수정안 A): Breakpoint로 막 돌아온 부분(인쇄·K, 색상, 속성 블록, 선/문자/치수, 그리기모드)은 실제 남은 공간이 있을 때만 돌아오고 없으면 More에 남으며, 다시 나타난 Open은 넓어지는 동안 다른 부분 때문에 More로 돌아가지 않습니다. 충분히 넓으면 모두 돌아옵니다. 메뉴 열기·닫기는 Header 요소의 위치·폭을 바꾸지 않습니다. 호스트 요소(`leading`·`tools`)는 More로 옮기지 않으므로 폭이 좁은 화면에는 많이 넣지 않습니다.
@@ -108,7 +110,9 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
| 동작 | Non-modal(Dim·Portal·Focus trap 없음). Escape는 Popup 안이면 그 Popup, 밖이면 최근 사용 Popup 하나. `autoFocus`면 열릴 때 Focus, 안에서 닫히면 원래 위치로 | — | | 동작 | Non-modal(Dim·Portal·Focus trap 없음). Escape는 Popup 안이면 그 Popup, 밖이면 최근 사용 Popup 하나. `autoFocus`면 열릴 때 Focus, 안에서 닫히면 원래 위치로 | — |
- **Header 필드 Dropdown(`SWHeaderLayerMenu`·`SWHeaderColorMenu`·선/문자/치수 스타일)은 SWHeader 전용 디자인입니다.** Popup 안에 넣지 않습니다(2026-10-20 사용자 확정). Popup의 레이어·색상·선스타일은 속성창 컨트롤 `SWPopupPropertyRow`(속성창과 같은 `picker` 필드: 레이어 26:12250, 색상 26:12170, 선스타일 26:12388 — 값 `#E60403`, Hover 1px `rgba(33,29,19,.4)`, On `#211D13`, 목록은 고정 Overlay)를 씁니다. 그 밖의 행은 `SWPopupRow` + `SWHeaderInput`. - **Header 필드 Dropdown(`SWHeaderLayerMenu`·`SWHeaderColorMenu`·선/문자/치수 스타일)은 SWHeader 전용 디자인입니다.** Popup 안에 넣지 않습니다(2026-10-20 사용자 확정). Popup의 레이어·색상·선스타일은 속성창 컨트롤 `SWPopupPropertyRow`(속성창과 같은 `picker` 필드: 레이어 26:12250, 색상 26:12170, 선스타일 26:12388 — 값 `#E60403`, Hover 1px `rgba(33,29,19,.4)`, On `#211D13`, 목록은 고정 Overlay)를 씁니다. 그 밖의 행은 `SWPopupRow` + `SWHeaderInput`.
- 선택 목록(단위 등)은 `SWSelect`입니다(native select, 28px · 좌우 8 · 1px `#948871` · radius 4 · 면 `#F1ECE1` · 12px 검정, 플랫폼 화살표·옵션 목록). 뷰어 축척 지정 팝업과 같은 모양이고 뷰어 패널 안에서는 `--hm-sw-viewer-panel-*` 값을 따릅니다. 토큰 `--hm-sw-select-border-color`·`-background`·`-text`. - 선택 목록(단위 등)은 `SWSelect`입니다(native select, 28px · 좌우 8 · 1px `#948871` · radius 4 · 면 `#F1ECE1` · 12px, 플랫폼 화살표·옵션 목록). 선택값은 변경 가능한 값이라 `#E60403`(2026-10-21), Disabled 값과 옵션 목록은 검정입니다. 뷰어 축척 지정 팝업과 같은 모양이고 뷰어 패널 안에서는 `--hm-sw-viewer-panel-*` 값(글자색 포함)을 따릅니다. 토큰 `--hm-sw-select-border-color`·`-background`·`-text`(Disabled 값·옵션 목록; Popup 안 선택값 빨강은 규칙으로 적용).
- **속성값 색상(2026-10-21 사용자 확정):** 빨강 `#E60403`은 실제로 수정 가능한 값에만 씁니다. 밝은 속성창·Popup에서 수정할 수 없는 값(`readOnly`·`type: "computed"`·Disabled, Callback 없는 행)은 검정입니다. 어두운 속성Bar는 Bar에서 편집하지 않는 값을 포커스 없는 텍스트(`<output>`)로 보여 주며, 속성창에서 수정하는 값(원의 반지름·지름·원주·면적)은 빨강을 유지합니다. 클릭할 수 있다는 이유만으로 빨강을 쓰지 않습니다.
- **속성창 입력(2026-10-21):** 숫자 값(선 가중치 포함)의 편집 Input은 값 영역 전체 너비를 채웁니다. 투명도는 값 + `annotation`(예 `(ByL)`)이 하나의 편집 상자이고 그 뒤에 게이지바·단위가 옵니다. 속성창 선꼴(`picker: "lineStyle"`) Dropdown은 선 종류 목록 1열(미리보기 + 이름)만 표시합니다. `menus.lineWeights`·`lineScale` 등은 속성창 Dropdown에 표시되지 않으며 SWHeader 선스타일 필드는 기존 3열을 유지합니다.
- 게이지바는 `SWSlider`입니다. 모양 선택 우선순위: ① `appearance` Prop(명시) → ② 기본 `"panel"`. 테마 연동은 토큰 범위입니다: `panel` = `--hm-sw-slider-panel-track`(`#E0DACE`)·`-range`(`#266651`)·`-thumb`(`#FFFFFF`), `property` = `--hm-sw-property-range-fill`·`-fill-edge`·`-track`·`-highlight`(속성창과 같은 토큰). `SWTheme style`·Runtime 토큰 Override로 두 모양의 색이 함께 바뀌며, 테마 전체에서 기본 `appearance`를 바꾸는 설정은 없습니다. 새 색을 만들지 않습니다. - 게이지바는 `SWSlider`입니다. 모양 선택 우선순위: ① `appearance` Prop(명시) → ② 기본 `"panel"`. 테마 연동은 토큰 범위입니다: `panel` = `--hm-sw-slider-panel-track`(`#E0DACE`)·`-range`(`#266651`)·`-thumb`(`#FFFFFF`), `property` = `--hm-sw-property-range-fill`·`-fill-edge`·`-track`·`-highlight`(속성창과 같은 토큰). `SWTheme style`·Runtime 토큰 Override로 두 모양의 색이 함께 바뀌며, 테마 전체에서 기본 `appearance`를 바꾸는 설정은 없습니다. 새 색을 만들지 않습니다.
- Popup 하단 버튼의 SW 디자인은 없습니다. 임의 색·크기로 만들지 말고 `@hm-design/ui` `Button` 등 기존 컴포넌트를 쓰거나 사용자에게 디자인을 확인합니다. - Popup 하단 버튼의 SW 디자인은 없습니다. 임의 색·크기로 만들지 말고 `@hm-design/ui` `Button` 등 기존 컴포넌트를 쓰거나 사용자에게 디자인을 확인합니다.
+3 -3
View File
@@ -42,7 +42,7 @@ import "@hm-design/sw/styles.css"
| `activeToolId` / `onToolSelect(toolId, categoryId, tool)` | `null` | 실행 중 명령 = 주황(Figma Variant3, `aria-pressed`) | | `activeToolId` / `onToolSelect(toolId, categoryId, tool)` | `null` | 실행 중 명령 = 주황(Figma Variant3, `aria-pressed`) |
| `slotCount` | `17` / `command`가 있으면 제한 없음 | 명령 + 빈 칸(+)의 최대 칸 수. **칸 영역의 폭은 바 폭으로만 정해지고 분류의 명령 수와 무관**합니다(분류를 바꿔도 칸 영역·명령창·명령어 전체보기·Layout 탭 위치가 그대로). 명령이 적으면 빈 칸, 많으면 마지막 칸의 ▲ 패널(§4.1) | | `slotCount` | `17` / `command`가 있으면 제한 없음 | 명령 + 빈 칸(+)의 최대 칸 수. **칸 영역의 폭은 바 폭으로만 정해지고 분류의 명령 수와 무관**합니다(분류를 바꿔도 칸 영역·명령창·명령어 전체보기·Layout 탭 위치가 그대로). 명령이 적으면 빈 칸, 많으면 마지막 칸의 ▲ 패널(§4.1) |
| `onSlotAdd(slotIndex, categoryId)` | 없음 | 빈 칸 클릭(명령 등록). **없으면 빈 칸은 모양만 있고 반응하지 않음** | | `onSlotAdd(slotIndex, categoryId)` | 없음 | 빈 칸 클릭(명령 등록). **없으면 빈 칸은 모양만 있고 반응하지 않음** |
| `slotGuide` | Figma 가이드 | 빈 칸 Hover·키보드 Focus 시 위에 뜨는 208 × 149 안내 `{ title = "명령어 등록", caption = "빠른사용", media? }`. `media`는 194 × 111 영상·이미지(프로그램 제공). `false` = 없음 | | `slotGuide` | `false` | 빈 칸 Hover·키보드 Focus 시 위에 뜨는 208 × 149 안내(선택 사용). 2026-10-21부터 기본은 없음(+ 위 미리보기 제거). 필요할 때만 `{}`(Figma 가이드 `명령어 등록` / `빠른사용`) 또는 `{ title, caption, media }`를 전달. `media`는 194 × 111 영상·이미지(프로그램 제공) |
| `onCategoryAdd` | 없음 | 분류 행 끝 + 버튼 표시 | | `onCategoryAdd` | 없음 | 분류 행 끝 + 버튼 표시 |
| `auxItems` / `onAuxPressedChange(id, pressed)` / `onAuxItemClick(id)` | 없음 | 3 × 2 보조기능 토글(`SWFooterAuxItem`: `id, label, icon?, pressed?, title?, disabled?`). `pressed`를 주면 토글 | | `auxItems` / `onAuxPressedChange(id, pressed)` / `onAuxItemClick(id)` | 없음 | 3 × 2 보조기능 토글(`SWFooterAuxItem`: `id, label, icon?, pressed?, title?, disabled?`). `pressed`를 주면 토글 |
| `auxAll` / `commandAll` | 표시 | 양끝 74 × 71 버튼 `{ title, subtitle, tooltip, onClick, pressed, disabled }`. 기본 문구 `보조기능 / 전체보기`, `명령어 / 전체보기`. `false` = 숨김 | | `auxAll` / `commandAll` | 표시 | 양끝 74 × 71 버튼 `{ title, subtitle, tooltip, onClick, pressed, disabled }`. 기본 문구 `보조기능 / 전체보기`, `명령어 / 전체보기`. `false` = 숨김 |
@@ -62,7 +62,7 @@ import "@hm-design/sw/styles.css"
| `SWFooterAuxGroup` | 104 × 33 칸, gap 2 | `items`(필수), `onPressedChange`, `onItemClick`, `columns`(3), `label` | | `SWFooterAuxGroup` | 104 × 33 칸, gap 2 | `items`(필수), `onPressedChange`, `onItemClick`, `columns`(3), `label` |
| `SWFooterAuxButton` | 104 × 33 | `label`(필수), `icon`, `pressed`, `button` 속성 | | `SWFooterAuxButton` | 104 × 33 | `label`(필수), `icon`, `pressed`, `button` 속성 |
| `SWFooterToolButton` | 68 × 47 칸 | `label`(필수), `icon`, `activeIcon`, `pressed`, `button` 속성(`title` 생략 시 잘린 라벨만 자동 Tooltip). 연속 배치하면 칸 사이 1px 금색 구분선 | | `SWFooterToolButton` | 68 × 47 칸 | `label`(필수), `icon`, `activeIcon`, `pressed`, `button` 속성(`title` 생략 시 잘린 라벨만 자동 Tooltip). 연속 배치하면 칸 사이 1px 금색 구분선 |
| `SWFooterAddSlot` | 68 × 47 칸(+ 68 × 46) | `guide`(기본 Figma 가이드 / `false`), `guideOpen`(Controlled), `onGuideOpenChange`, `button` 속성 | | `SWFooterAddSlot` | 68 × 47 칸(+ 68 × 46) | `guide`(기본 `false` = 없음 / `{}` Figma 가이드 / 내용 객체), `guideOpen`(Controlled), `onGuideOpenChange`, `button` 속성 |
| `SWFooterGuide` | 208 × 149 | `title`, `caption`, `media`, `div` 속성. 단독 카드로 둘 때 `data-standalone=""` | | `SWFooterGuide` | 208 × 149 | `title`, `caption`, `media`, `div` 속성. 단독 카드로 둘 때 `data-standalone=""` |
| `SWFooterMenu` | 높이 24 | `items`(`{ id, label, disabled? }[]`, 필수), `value`(필수), `onValueChange`, `onAdd`, `addLabel`, `label`, `panelId`, `tabIdPrefix`. 현재 탭은 위 칸과 이어지는 Pocket 모양(SVG, 위치 자동 계산) | | `SWFooterMenu` | 높이 24 | `items`(`{ id, label, disabled? }[]`, 필수), `value`(필수), `onValueChange`, `onAdd`, `addLabel`, `label`, `panelId`, `tabIdPrefix`. 현재 탭은 위 칸과 이어지는 Pocket 모양(SVG, 위치 자동 계산) |
| `SWFooterLayoutTabs` | 높이 22 | `layouts`(`SWFooterLayout[]` = `{ id, label, locked?, shape? }`, 필수), `value`(layout id 또는 `SW_FOOTER_MODEL`), `onValueChange`, `onAdd`, `onRemove`, `onRename`, `editingId`/`onEditingChange`, `modelLabel`(`"Model"` / `false`), `label`, `addLabel`, `removeLabel` | | `SWFooterLayoutTabs` | 높이 22 | `layouts`(`SWFooterLayout[]` = `{ id, label, locked?, shape? }`, 필수), `value`(layout id 또는 `SW_FOOTER_MODEL`), `onValueChange`, `onAdd`, `onRemove`, `onRename`, `editingId`/`onEditingChange`, `modelLabel`(`"Model"` / `false`), `label`, `addLabel`, `removeLabel` |
@@ -79,7 +79,7 @@ import "@hm-design/sw/styles.css"
|---|---|---| |---|---|---|
| 명령 | Default `#F6F4F2` / Variant2 Hover = Variant3 선택: `#FF5C00` + 주황 아이콘 | Hover는 CSS, 선택은 `activeToolId`·`pressed`. 아이콘·라벨 위치·크기와 칸 크기는 모든 상태에서 같음 | | 명령 | Default `#F6F4F2` / Variant2 Hover = Variant3 선택: `#FF5C00` + 주황 아이콘 | Hover는 CSS, 선택은 `activeToolId`·`pressed`. 아이콘·라벨 위치·크기와 칸 크기는 모든 상태에서 같음 |
| 분류 탭 | Default `#D4CBBD` / Hover·현재 `#FF5C00`, 현재 탭은 Pocket | `categoryId`·`value` | | 분류 탭 | Default `#D4CBBD` / Hover·현재 `#FF5C00`, 현재 탭은 Pocket | `categoryId`·`value` |
| 빈 칸 | Default + / Hover 주황 점선 링 + 주황 + | Hover·Focus 시 `slotGuide` 표시 | | 빈 칸 | Default + / Hover 주황 점선 링 + 주황 + | `slotGuide`를 전달한 경우에만 Hover·Focus 시 안내 표시(기본 없음) |
| Layout 탭 | Default 베이지 `#C3B79E` / Hover(선택 아님, ×) = Model과 같은 주황 그라데이션 면(글자는 Default와 동일: 검정 `#161410`, 크기·굵기·위치 불변) / On 갈색 그라데이션·흰 글자 / On hover / Variant5 이름 편집(`#0F0D0A`, 빨강 `#E60403`) / Variant6 편집 + Hover | `value`, 편집 = 더블클릭·F2 또는 `editingId` | | Layout 탭 | Default 베이지 `#C3B79E` / Hover(선택 아님, ×) = Model과 같은 주황 그라데이션 면(글자는 Default와 동일: 검정 `#161410`, 크기·굵기·위치 불변) / On 갈색 그라데이션·흰 글자 / On hover / Variant5 이름 편집(`#0F0D0A`, 빨강 `#E60403`) / Variant6 편집 + Hover | `value`, 편집 = 더블클릭·F2 또는 `editingId` |
| Model 탭 | Default 베이지 / Hover·On = 주황 그라데이션 면(`gra-border-orange`) + 검정 `#161410` 글자 | `value === SW_FOOTER_MODEL` | | Model 탭 | Default 베이지 / Hover·On = 주황 그라데이션 면(`gra-border-orange`) + 검정 `#161410` 글자 | `value === SW_FOOTER_MODEL` |
| `+` 버튼 | Layout 옆 `+` Hover = 같은 주황 그라데이션 면 + `+`. 분류 행 `+` Hover = `+` `#FF5C00` | `onAdd` / `onCategoryAdd` | | `+` 버튼 | Layout 옆 `+` Hover = 같은 주황 그라데이션 면 + `+`. 분류 행 `+` Hover = `+` `#FF5C00` | `onAdd` / `onCategoryAdd` |
@@ -39,7 +39,10 @@ export interface SWFooterProps extends Omit<HTMLAttributes<HTMLElement>, "childr
slotCount?: number slotCount?: number
/** An empty + slot was clicked (register a command). `slotIndex` is 0-based in the row. Omitted: the empty slots are inert. */ /** An empty + slot was clicked (register a command). `slotIndex` is 0-based in the row. Omitted: the empty slots are inert. */
onSlotAdd?: (slotIndex: number, categoryId: string) => void onSlotAdd?: (slotIndex: number, categoryId: string) => void
/** Guide over a hovered / focused empty slot. Default: the Figma guide (`명령어 등록` / `빠른사용`). `false`: none. */ /**
* Guide over a hovered / focused empty slot (opt-in). Default `false` (2026-10-21: no preview over the + slot). `{}`:
* the Figma guide (`명령어 등록` / `빠른사용`), or pass its title / caption / media.
*/
slotGuide?: SWFooterGuideContent | false slotGuide?: SWFooterGuideContent | false
/** Shows the + after the categories. */ /** Shows the + after the categories. */
onCategoryAdd?: () => void onCategoryAdd?: () => void
@@ -76,7 +79,7 @@ const SLOT = 68
*/ */
export const SWFooter = forwardRef<HTMLElement, SWFooterProps>(function SWFooter({ export const SWFooter = forwardRef<HTMLElement, SWFooterProps>(function SWFooter({
categories, categoryId, defaultCategoryId, onCategoryChange, activeToolId = null, onToolSelect, categories, categoryId, defaultCategoryId, onCategoryChange, activeToolId = null, onToolSelect,
slotCount, onSlotAdd, slotGuide = {}, onCategoryAdd, slotCount, onSlotAdd, slotGuide = false, onCategoryAdd,
auxItems, onAuxPressedChange, onAuxItemClick, auxAll = {}, commandAll = {}, auxItems, onAuxPressedChange, onAuxItemClick, auxAll = {}, commandAll = {},
command, status, layoutTabs, trailing, label = "명령 도구", className, ...rest command, status, layoutTabs, trailing, label = "명령 도구", className, ...rest
}, ref) { }, ref) {
@@ -153,8 +153,8 @@ export const SWFooterGuide = forwardRef<HTMLDivElement, SWFooterGuideProps>(func
export interface SWFooterAddSlotProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> { export interface SWFooterAddSlotProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
/** /**
* Guide shown above the slot while it is hovered or focused. Default: the Figma guide (`명령어 등록` / `빠른사용`, * Guide shown above the slot while it is hovered or focused (opt-in). Default `false` (2026-10-21: no preview over
* empty preview). `false`: no guide. * the + slot). `{}`: the Figma guide (`명령어 등록` / `빠른사용`, empty preview), or pass its title / caption / media.
*/ */
guide?: SWFooterGuideContent | false guide?: SWFooterGuideContent | false
/** Shows the guide (controlled). Default: while hovered or keyboard focused. */ /** Shows the guide (controlled). Default: while hovered or keyboard focused. */
@@ -165,7 +165,7 @@ export interface SWFooterAddSlotProps extends Omit<ButtonHTMLAttributes<HTMLButt
/** 68 × 46 empty command slot (Figma btn_add_menu 26:9971): the + well; hover draws the orange dashed ring. */ /** 68 × 46 empty command slot (Figma btn_add_menu 26:9971): the + well; hover draws the orange dashed ring. */
export const SWFooterAddSlot = forwardRef<HTMLButtonElement, SWFooterAddSlotProps>(function SWFooterAddSlot( export const SWFooterAddSlot = forwardRef<HTMLButtonElement, SWFooterAddSlotProps>(function SWFooterAddSlot(
{ guide = {}, guideOpen, onGuideOpenChange, className, type = "button", onMouseEnter, onMouseLeave, onFocus, onBlur, ...rest }, ref, { guide = false, guideOpen, onGuideOpenChange, className, type = "button", onMouseEnter, onMouseLeave, onFocus, onBlur, ...rest }, ref,
) { ) {
const [hovered, setOpen] = useState(false) const [hovered, setOpen] = useState(false)
const open = guideOpen ?? hovered const open = guideOpen ?? hovered
@@ -41,6 +41,8 @@ const defaultControls: SWHeaderControlConfig[] = (["document", "drawingMode", ..
const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove", "minimize", "close"] const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove", "minimize", "close"]
const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"] const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"]
const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot
/** Header width from which nothing moves into More (2026-10-21): the header compresses instead. Below it, as before. */
const MORE_BELOW = 760
/** /**
* Order in which visible parts move into More when the measured object area has no room left (on top of the * Order in which visible parts move into More when the measured object area has no room left (on top of the
@@ -318,7 +320,10 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot) const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot)
const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0] const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0]
const validPromoted = promoted && enabled(promoted) ? promoted : null const validPromoted = promoted && enabled(promoted) ? promoted : null
const breakpointOverflowAt = (id: SWHeaderControlId, width: number) => id === "color" ? width < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? width < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? width < 1080 : ["print", "popupGuide"].includes(id) ? width < 640 : id === "drawingMode" ? width < 1180 : false // 2026-10-21 (user decision): no responsive More from 760px up. Nothing moves out of the header there; narrower headers
// hide the field titles, then shrink the file name, then the field values (ellipsis, sw.css "Responsive"). Below 760px
// (phones, e.g. 390) the former breakpoints and More are unchanged.
const breakpointOverflowAt = (id: SWHeaderControlId, width: number) => width < MORE_BELOW && (id === "color" ? width < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? width < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? width < 1080 : ["print", "popupGuide"].includes(id) ? width < 640 : id === "drawingMode" ? width < 1180 : false)
const breakpointOverflow = (id: SWHeaderControlId) => breakpointOverflowAt(id, headerWidth) const breakpointOverflow = (id: SWHeaderControlId) => breakpointOverflowAt(id, headerWidth)
// Measured More: the parts that can move (the default layout moves the property block as one part; print / K sit in // Measured More: the parts that can move (the default layout moves the property block as one part; print / K sit in
// the window area there and keep the breakpoint). Keys of the moved parts are kept in move order. // the window area there and keep the breakpoint). Keys of the moved parts are kept in move order.
@@ -373,6 +378,13 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
collapseRef.current = null collapseRef.current = null
if (openCollapsed) return setOpenCollapsed(false) if (openCollapsed) return setOpenCollapsed(false)
} }
// No responsive More from 760px up (2026-10-21): the header compresses instead. Below it the measured More runs as
// before; parts it moved leave silently once the header is that wide again.
if (headerWidth >= MORE_BELOW) {
if (squeezed.length) { squeezeRef.current.clear(); return setSqueezed([]) }
if (openCollapsed) { collapseRef.current = null; return setOpenCollapsed(false) }
return
}
if (widthRef.current.current !== headerWidth) widthRef.current = { previous: widthRef.current.current, current: headerWidth } if (widthRef.current.current !== headerWidth) widthRef.current = { previous: widthRef.current.current, current: headerWidth }
const { previous } = widthRef.current const { previous } = widthRef.current
if (releasedRef.current.width !== headerWidth) releasedRef.current = { width: headerWidth, keys: new Set() } if (releasedRef.current.width !== headerWidth) releasedRef.current = { width: headerWidth, keys: new Set() }
@@ -24,13 +24,15 @@ function BarField({ property, onPropertyChange, readOnly, disabled }: Pick<SWHea
return ( return (
<div className="hm-sw-property-bar__field" data-property-id={property.id}> <div className="hm-sw-property-bar__field" data-property-id={property.id}>
<label htmlFor={id}>{property.name}</label> <label htmlFor={id}>{property.name}</label>
<SWHeaderInput id={id} value={property.mixed ? "다양함" : property.value} {/* 2026-10-21: a value that cannot be edited here (read-only, computed, disabled) is plain text: no input, no
focus stop. It still follows the host value. */}
{!editable ? <output id={id} className="hm-sw-property-bar__value" data-disabled={disabled || property.disabled ? "true" : undefined}>{property.mixed ? "다양함" : property.value}</output> : <SWHeaderInput id={id} value={property.mixed ? "다양함" : property.value}
valueType={property.type === "number" || property.type === "slider" ? "number" : "text"} valueType={property.type === "number" || property.type === "slider" ? "number" : "text"}
disabled={disabled || property.disabled} readOnly={!editable} parse={parse} commitOnUnmount={false} disabled={disabled || property.disabled} readOnly={!editable} parse={parse} commitOnUnmount={false}
onCommit={editable && (onPropertyChange || property.onCommit) ? next => { onCommit={editable && (onPropertyChange || property.onCommit) ? next => {
property.onCommit?.(next, property) property.onCommit?.(next, property)
onPropertyChange?.(property, next) onPropertyChange?.(property, next)
} : undefined} /> } : undefined} />}
{property.unit && <span>{property.unit}</span>} {property.unit && <span>{property.unit}</span>}
</div> </div>
) )
@@ -116,8 +116,6 @@ export function SWHeaderPropertyPickerField({ picker, property, id, editable, me
if (picker === "lineStyle") { if (picker === "lineStyle") {
const lineTypes = propertyLineTypes(property, menus) const lineTypes = propertyLineTypes(property, menus)
const current = mixed ? undefined : byValue(lineTypes, value) const current = mixed ? undefined : byValue(lineTypes, value)
const weights = menus?.lineWeights
const currentWeight = weights?.find(item => item.id === menus?.activeLineWeightId)
const text = mixed ? "다양함" : current?.name ?? property.options?.find(option => option.id === value)?.name ?? value const text = mixed ? "다양함" : current?.name ?? property.options?.find(option => option.id === value)?.name ?? value
return ( return (
<div {...shared}> <div {...shared}>
@@ -128,17 +126,13 @@ export function SWHeaderPropertyPickerField({ picker, property, id, editable, me
</span> </span>
{property.linePreview !== false && <SWHeaderLinePreview pattern={current?.pattern ?? property.linePattern} />} {property.linePreview !== false && <SWHeaderLinePreview pattern={current?.pattern ?? property.linePattern} />}
</button> </button>
{open && drop("", "선스타일", ( {/* 2026-10-21 design review: the property window opens the line type list only (선꼴: preview + name); weight
and scale are their own rows. The SWHeader line style field keeps its 3 columns. */}
{open && drop("", property.name, (
<SWHeaderLineStyleColumns <SWHeaderLineStyleColumns
lineTypes={lineTypes} lineTypes={lineTypes}
lineWeights={weights}
currentType={current} currentType={current}
currentWeight={currentWeight}
scale={menus?.lineScale}
onLineTypeSelect={item => { commit(item.id); close(true) }} onLineTypeSelect={item => { commit(item.id); close(true) }}
onLineWeightSelect={menus?.onLineWeightSelect}
onLineWeightValueChange={menus?.onLineWeightValueChange}
onScaleChange={menus?.onLineScaleChange}
onDetail={menus?.onLineStyleDetail && (() => { menus.onLineStyleDetail?.(); close(true) })} onDetail={menus?.onLineStyleDetail && (() => { menus.onLineStyleDetail?.(); close(true) })}
/> />
))} ))}
@@ -217,40 +217,47 @@ export function PropertyEditor({ property, onChange, menus }: { property: SWHead
if (event.key === "Enter") { event.preventDefault(); commit(draft) } if (event.key === "Enter") { event.preventDefault(); commit(draft) }
if (event.key === "Escape" && draft !== value) { event.stopPropagation(); cancelDraft() } if (event.key === "Escape" && draft !== value) { event.stopPropagation(); cancelDraft() }
} }
// Figma 26:12649: a number without unit / annotation (선가중치) and the slider value are the 41px hug field; // Every value is the 200px box (26:12314 / 26:12590 / 26:12583 / 26:12574). 2026-10-21 design review: a bare
// every other value is the 200px box (26:12314 / 26:12590 / 26:12583 / 26:12574). Figma has an editing On // number (선 가중치) is no longer the 41px hug field; its editing box fills the value area like 선 축척. The slider
// (#0F0D0A) only for numbers; text and computed values keep Default / Hover (no invented On). // value is its own box that fills the room before the gauge (투명도). Figma has an editing On (#0F0D0A) only for
const compact = type === "slider" || (type === "number" && !unit && !property.annotation) // numbers; text and computed values keep Default / Hover (no invented On).
const field = compact ? undefined : "box" const field = type === "slider" ? undefined : "box"
const focusInput = (event: React.MouseEvent<HTMLElement>) => {
// A click on the unit / empty part of the box edits the value (the box is the Figma field).
if (!editable || event.target === inputRef.current || type === "select") return
event.preventDefault()
inputRef.current?.focus()
}
const input = (
<SWHeaderInput
ref={inputRef}
id={inputId} aria-label={property.name} value={mixed ? "다양함" : value}
valueType={type === "number" || type === "slider" ? "number" : "text"}
disabled={disabled} readOnly={!editable} parse={parse} commitOnUnmount={false}
onCommit={editable ? commit : undefined}
/>
)
return ( return (
<div className="hm-sw-property__row" data-property-id={property.id} data-type={type} data-disabled={disabled || undefined}> <div className="hm-sw-property__row" data-property-id={property.id} data-type={type} data-disabled={disabled || undefined}>
<label htmlFor={inputId} title={property.name}>{property.name}</label> <label htmlFor={inputId} title={property.name}>{property.name}</label>
{picker ? <SWHeaderPropertyPickerField picker={picker} property={property} id={inputId} editable={editable} menus={menus} commit={commit} parse={parse} /> : ( {picker ? <SWHeaderPropertyPickerField picker={picker} property={property} id={inputId} editable={editable} menus={menus} commit={commit} parse={parse} /> : (
<div className="hm-sw-property__value" data-field={field} data-on={field && editable && type === "number" ? "edit" : undefined} <div className="hm-sw-property__value" data-field={field} data-on={field && editable && type === "number" ? "edit" : undefined}
data-readonly={editable ? undefined : "true"} data-readonly={editable ? undefined : "true"}
onMouseDown={event => { onMouseDown={field ? focusInput : undefined}>
// A click on the unit / empty part of the box edits the value (the box is the Figma field).
if (!field || !editable || event.target === inputRef.current || type === "select") return
event.preventDefault()
inputRef.current?.focus()
}}>
{type === "select" ? ( {type === "select" ? (
<select id={inputId} aria-label={property.name} value={mixed && draft === value ? "__mixed" : draft} disabled={!editable} onChange={event => setDraft(event.target.value)} onBlur={() => commit(draft)} onKeyDown={nativeKeys}> <select id={inputId} aria-label={property.name} value={mixed && draft === value ? "__mixed" : draft} disabled={!editable} onChange={event => setDraft(event.target.value)} onBlur={() => commit(draft)} onKeyDown={nativeKeys}>
{mixed && <option value="__mixed" disabled>다양함</option>} {mixed && <option value="__mixed" disabled>다양함</option>}
{!mixed && !property.options?.some(option => option.id === value) && <option value={value}>{value}</option>} {!mixed && !property.options?.some(option => option.id === value) && <option value={value}>{value}</option>}
{property.options?.map(option => <option key={option.id} value={option.id} disabled={option.disabled}>{option.name}</option>)} {property.options?.map(option => <option key={option.id} value={option.id} disabled={option.disabled}>{option.name}</option>)}
</select> </select>
) : ( ) : type === "slider" ? (
<SWHeaderInput // Slider value box: the value and its annotation ((ByL)) are one editing field; the gauge and unit follow.
ref={inputRef} <span className="hm-sw-property__slider-value" data-on={editable ? "edit" : undefined} data-readonly={editable ? undefined : "true"} onMouseDown={focusInput}>
id={inputId} aria-label={property.name} value={mixed ? "다양함" : value} {input}
className={compact ? "hm-sw-property__compact" : undefined} {property.annotation && <span className="hm-sw-property__unit">{property.annotation}</span>}
valueType={type === "number" || type === "slider" ? "number" : "text"} </span>
disabled={disabled} readOnly={!editable} parse={parse} commitOnUnmount={false} ) : input}
onCommit={editable ? commit : undefined} {type !== "slider" && property.annotation && <span className="hm-sw-property__unit">{property.annotation}</span>}
/>
)}
{property.annotation && <span className="hm-sw-property__unit">{property.annotation}</span>}
{unit && type !== "slider" && <span className="hm-sw-property__unit">{unit}</span>} {unit && type !== "slider" && <span className="hm-sw-property__unit">{unit}</span>}
{type === "color" && <span className="hm-sw-property__swatch-area" aria-hidden="true"><span className="hm-sw-property__swatch"><i style={{ backgroundColor: property.swatch ?? parseSWHeaderColor(value)?.hex ?? "transparent" }} /></span></span>} {type === "color" && <span className="hm-sw-property__swatch-area" aria-hidden="true"><span className="hm-sw-property__swatch"><i style={{ backgroundColor: property.swatch ?? parseSWHeaderColor(value)?.hex ?? "transparent" }} /></span></span>}
{property.linePreview && <SWHeaderLinePreview pattern={property.linePattern} />} {property.linePreview && <SWHeaderLinePreview pattern={property.linePattern} />}
@@ -4,6 +4,17 @@ import { useSWHomeMenuClose } from "./SWHomeMenu"
import type { SWFileEntry } from "./types" import type { SWFileEntry } from "./types"
type PreviewSide = "right" | "left" | "below" | "above" type PreviewSide = "right" | "left" | "below" | "above"
const MAX_RECENT_FILES = 3
/** Preview caption (2026-10-21): the folder `path` + the file name; a path that already ends with the name is kept as is. */
function previewCaption(file: SWFileEntry) {
if (file.previewTitle) return file.previewTitle
const path = file.path?.replace(/[\\/]+$/, "")
if (!path) return file.name
const rest = path.slice(0, path.length - file.name.length)
if (path.endsWith(file.name) && (rest === "" || /[\\/]$/.test(rest))) return path
return `${path}${path.includes("/") && !path.includes("\\") ? "/" : "\\"}${file.name}`
}
export interface SWRecentFilesProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> { export interface SWRecentFilesProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
/** Host-owned list (most recent first). Nothing is read from disk here. */ /** Host-owned list (most recent first). Nothing is read from disk here. */
@@ -24,7 +35,8 @@ export interface SWRecentFilesProps extends Omit<HTMLAttributes<HTMLDivElement>,
/** /**
* Figma 최근파일 662:67331: 354px recent-file list (rows 50px: optional 29 x 28 badge, name 14px, path 12px) * Figma 최근파일 662:67331: 354px recent-file list (rows 50px: optional 29 x 28 badge, name 14px, path 12px)
* with the 2-depth preview 662:67471 beside the hovered / focused row. The list height follows the rows and is * with the 2-depth preview 662:67471 beside the hovered / focused row. The list height follows the rows and is
* capped (`--hm-sw-recent-files-max-height`, Figma 750 = 15 rows); longer lists scroll inside. * capped (`--hm-sw-recent-files-max-height`, Figma 750 = 15 rows); longer lists scroll inside. Only the first 3
* files are shown (2026-10-21).
*/ */
export function SWRecentFiles({ export function SWRecentFiles({
files, onFileOpen, onFileHover, renderPreview, closeOnOpen = true, emptyText = "최근 파일이 없습니다", files, onFileOpen, onFileHover, renderPreview, closeOnOpen = true, emptyText = "최근 파일이 없습니다",
@@ -35,8 +47,12 @@ export function SWRecentFiles({
const listRef = useRef<HTMLDivElement>(null) const listRef = useRef<HTMLDivElement>(null)
const previewRef = useRef<HTMLDivElement>(null) const previewRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState<{ id: string; top: number; rowHeight: number; side: PreviewSide } | null>(null) const [hovered, setHovered] = useState<{ id: string; top: number; rowHeight: number; side: PreviewSide } | null>(null)
const current = hovered ? files.find(file => file.id === hovered.id) : undefined // 2026-10-21 (user decision): only the 3 most recent files are shown; `files` itself is not trimmed.
const preview = current ? current.preview ?? renderPreview?.(current) : undefined const shown = files.slice(0, MAX_RECENT_FILES)
const current = hovered ? shown.find(file => file.id === hovered.id) : undefined
// 2026-10-21 (user decision): the 2-depth preview keeps its frame, size and position on hover, but its body stays
// empty — no image / thumbnail is rendered. `preview` / `renderPreview` stay accepted (public types unchanged).
void renderPreview
const show = (file: SWFileEntry | null, row?: HTMLElement) => { const show = (file: SWFileEntry | null, row?: HTMLElement) => {
const list = listRef.current const list = listRef.current
@@ -84,7 +100,7 @@ export function SWRecentFiles({
<div ref={listRef} className="hm-sw-recent-files__list" id={listId} role="list" aria-label={rest["aria-label"] ?? "최근 파일"} <div ref={listRef} className="hm-sw-recent-files__list" id={listId} role="list" aria-label={rest["aria-label"] ?? "최근 파일"}
onScroll={() => hovered && setHovered(null)}> onScroll={() => hovered && setHovered(null)}>
{files.length === 0 && <div className="hm-sw-recent-files__empty" role="listitem">{emptyText}</div>} {files.length === 0 && <div className="hm-sw-recent-files__empty" role="listitem">{emptyText}</div>}
{files.map(file => ( {shown.map(file => (
<div key={file.id} role="listitem" className="hm-sw-recent-files__item"> <div key={file.id} role="listitem" className="hm-sw-recent-files__item">
<button type="button" className="hm-sw-recent-files__row" data-file-id={file.id} <button type="button" className="hm-sw-recent-files__row" data-file-id={file.id}
data-hovered={hovered?.id === file.id ? "true" : undefined} disabled={file.disabled} data-hovered={hovered?.id === file.id ? "true" : undefined} disabled={file.disabled}
@@ -100,12 +116,18 @@ export function SWRecentFiles({
</div> </div>
))} ))}
</div> </div>
{current && preview != null && ( {current && (
<div ref={previewRef} className="hm-sw-recent-files__preview" data-side={hovered?.side} aria-hidden="true" <div ref={previewRef} className="hm-sw-recent-files__preview" data-side={hovered?.side} aria-hidden="true"
style={{ top: hovered?.side === "below" ? hovered.top - 1 + hovered.rowHeight style={{ top: hovered?.side === "below" ? hovered.top - 1 + hovered.rowHeight
: hovered?.side === "above" ? hovered.top - 1 : hovered?.top }}> : hovered?.side === "above" ? hovered.top - 1 : hovered?.top }}>
<span className="hm-sw-recent-files__preview-title">{current.previewTitle ?? current.path ?? current.name}</span> <span className="hm-sw-recent-files__preview-title"><span dir="ltr">{previewCaption(current)}</span></span>
<div className="hm-sw-recent-files__preview-body">{preview}</div> <div className="hm-sw-recent-files__preview-body">
{/* No preview (2026-10-21 design): the red prohibition sign centered on the preview panel. */}
<svg className="hm-sw-recent-files__preview-none" viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="44.5" />
<path d="M18.5 18.5 81.5 81.5" />
</svg>
</div>
</div> </div>
)} )}
</div> </div>
@@ -1,10 +1,13 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="132" height="27" viewBox="0 0 132 27" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="132" height="27" viewBox="0 0 132 27" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- 2026-10-21: Figma 26:10591 tit_* draws the badge narrow on top and flaring out onto the tile group below
(the earlier export was upside down). Shape and gradients are mirrored vertically (y -> 31 - y); the
-2 r4 shadow stays upward like the Figma effect. -->
<g id="Union" filter="url(#filter0_d_0_457)"> <g id="Union" filter="url(#filter0_d_0_457)">
<use xlink:href="#stroke0_0_457" fill="url(#paint0_linear_0_457)"/> <use xlink:href="#stroke0_0_457" fill="url(#paint0_linear_0_457)"/>
<mask id="stroke1_0_457" fill="white"> <mask id="stroke1_0_457" fill="white">
<use xlink:href="#stroke0_0_457"/> <use xlink:href="#stroke0_0_457"/>
</mask> </mask>
<path d="M27 6H4C9.02879 6.86861 12.6686 11.6685 16.1248 16.226C19.5413 20.7314 22.7783 25 27 25H28H104H105C109.222 25 112.459 20.7314 115.875 16.226C119.331 11.6685 122.971 6.86861 128 6H105H104H28H27Z" stroke-width="2" stroke="url(#paint1_linear_0_457)" mask="url(#stroke1_0_457)"/> <path d="M27 25H4C9.02879 24.13139 12.6686 19.3315 16.1248 14.774C19.5413 10.2686 22.7783 6 27 6H28H104H105C109.222 6 112.459 10.2686 115.875 14.774C119.331 19.3315 122.971 24.13139 128 25H105H104H28H27Z" stroke-width="2" stroke="url(#paint1_linear_0_457)" mask="url(#stroke1_0_457)"/>
</g> </g>
<defs> <defs>
<filter id="filter0_d_0_457" x="0" y="0" width="132" height="27" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> <filter id="filter0_d_0_457" x="0" y="0" width="132" height="27" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
@@ -17,15 +20,15 @@
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_457"/> <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_457"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_0_457" result="shape"/> <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_0_457" result="shape"/>
</filter> </filter>
<linearGradient id="paint0_linear_0_457" x1="66.3862" y1="6" x2="66.3862" y2="25" gradientUnits="userSpaceOnUse"> <linearGradient id="paint0_linear_0_457" x1="66.3862" y1="25" x2="66.3862" y2="6" gradientUnits="userSpaceOnUse">
<stop stop-color="#887757"/> <stop stop-color="#887757"/>
<stop offset="0.755507" stop-color="#A8997C"/> <stop offset="0.755507" stop-color="#A8997C"/>
<stop offset="1" stop-color="#C3B79E"/> <stop offset="1" stop-color="#C3B79E"/>
</linearGradient> </linearGradient>
<linearGradient id="paint1_linear_0_457" x1="66.3862" y1="6" x2="66.3862" y2="28" gradientUnits="userSpaceOnUse"> <linearGradient id="paint1_linear_0_457" x1="66.3862" y1="25" x2="66.3862" y2="3" gradientUnits="userSpaceOnUse">
<stop stop-color="#7B6332"/> <stop stop-color="#7B6332"/>
<stop offset="1" stop-color="#9F8D68"/> <stop offset="1" stop-color="#9F8D68"/>
</linearGradient> </linearGradient>
<path id="stroke0_0_457" d="M4 6H27H28H104H105H128C122.971 6.86861 119.331 11.6685 115.875 16.226C112.459 20.7314 109.222 25 105 25H104H28H27C22.7783 25 19.5413 20.7314 16.1248 16.226C12.6686 11.6685 9.02879 6.86861 4 6Z" fill-rule="evenodd" clip-rule="evenodd"/> <path id="stroke0_0_457" d="M4 25H27H28H104H105H128C122.971 24.13139 119.331 19.3315 115.875 14.774C112.459 10.2686 109.222 6 105 6H104H28H27C22.7783 6 19.5413 10.2686 16.1248 14.774C12.6686 19.3315 9.02879 24.13139 4 25Z" fill-rule="evenodd" clip-rule="evenodd"/>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -109,7 +109,7 @@ export interface SWFileEntry {
badge?: ReactNode badge?: ReactNode
/** 2-depth preview shown beside the hovered / focused row (Figma 파일출력 2뎁스 662:67471). */ /** 2-depth preview shown beside the hovered / focused row (Figma 파일출력 2뎁스 662:67471). */
preview?: ReactNode preview?: ReactNode
/** Preview caption; defaults to `path`. */ /** Preview caption; defaults to `path` + `name` (2026-10-21; a `path` already ending with the name is not doubled). */
previewTitle?: string previewTitle?: string
disabled?: boolean disabled?: boolean
title?: string title?: string
+21 -7
View File
@@ -53,6 +53,9 @@
--hm-sw-recent-files-row-hover-color: var(--hm-sw-header-layer-row-hover-color); --hm-sw-recent-files-row-hover-color: var(--hm-sw-header-layer-row-hover-color);
--hm-sw-recent-files-preview-width: 310px; --hm-sw-recent-files-preview-width: 310px;
--hm-sw-recent-files-preview-background: #62533d; --hm-sw-recent-files-preview-background: #62533d;
/* No preview (2026-10-21 design): the panel brown behind a red prohibition sign. */
--hm-sw-recent-files-preview-body-background: var(--hm-sw-recent-files-preview-background);
--hm-sw-recent-files-preview-none-color: #ff0000;
position: relative; position: relative;
isolation: isolate; isolation: isolate;
@@ -241,7 +244,7 @@
padding: 1px 23px 0; padding: 1px 23px 0;
color: var(--hm-sw-home-menu-title-color); color: var(--hm-sw-home-menu-title-color);
font-size: var(--hm-sw-header-label-size); font-size: var(--hm-sw-header-label-size);
font-weight: 400; font-weight: 700; /* 2026-10-21 user request: group title Bold (was Figma 400). */
line-height: 14px; line-height: 14px;
letter-spacing: 0.02em; letter-spacing: 0.02em;
white-space: nowrap; white-space: nowrap;
@@ -319,10 +322,11 @@
pointer-events: none; pointer-events: none;
} }
/* 18 x 18 icon slot at (31, 6); larger art is centered on it and may overflow (Figma assets carry shadows). */ /* 18 x 18 icon slot at (31, 4); larger art is centered on it and may overflow (Figma assets carry shadows).
2026-10-21 user decision: top 4px (was Figma 6px) so the icon no longer touches the bold label below it. */
.hm-sw-home-menu__icon { .hm-sw-home-menu__icon {
position: absolute; position: absolute;
top: 6px; top: 4px;
left: 31px; left: 31px;
display: grid; display: grid;
place-items: center; place-items: center;
@@ -406,15 +410,17 @@
.hm-sw-recent-files__name, .hm-sw-recent-files__name,
.hm-sw-recent-files__path { .hm-sw-recent-files__path {
display: block; display: block;
overflow: hidden; /* clip (not hidden): overflow-clip-margin only applies to clip, so the outline of the first glyph is not cut at the
left edge (2026-10-21 hover clipping report); the ellipsis and the text box are unchanged. */
overflow: clip;
overflow-clip-margin: var(--hm-sw-header-text-outline-room); overflow-clip-margin: var(--hm-sw-header-text-outline-room);
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.hm-sw-recent-files__name { color: var(--hm-sw-header-text-color); font-size: var(--hm-sw-header-font-size); font-weight: 700; line-height: 16px; text-shadow: var(--hm-sw-header-text-outline); } .hm-sw-recent-files__name { color: var(--hm-sw-header-text-color); font-size: var(--hm-sw-header-font-size); font-weight: 700; line-height: 20px; text-shadow: var(--hm-sw-header-text-outline); }
/* Path Bold with its 12px black outline (2026-10-20 user request), like the file name above (700 + 14px outline). */ /* Path Bold with its 12px black outline (2026-10-20 user request), like the file name above (700 + 14px outline). */
.hm-sw-recent-files__path { color: var(--hm-sw-recent-files-path-color); font-size: var(--hm-sw-header-label-size); font-weight: 700; line-height: 14px; opacity: 0.6; text-shadow: var(--hm-sw-header-label-outline); } .hm-sw-recent-files__path { color: var(--hm-sw-recent-files-path-color); font-size: var(--hm-sw-header-label-size); font-weight: 700; line-height: 16px; opacity: 0.6; text-shadow: var(--hm-sw-header-label-outline); }
.hm-sw-recent-files__row:is(:hover, [data-hovered="true"]):not(:disabled) { background: var(--hm-sw-recent-files-row-hover-background); } .hm-sw-recent-files__row:is(:hover, [data-hovered="true"]):not(:disabled) { background: var(--hm-sw-recent-files-row-hover-background); }
.hm-sw-recent-files__row:is(:hover, [data-hovered="true"]):not(:disabled) .hm-sw-recent-files__name { color: var(--hm-sw-recent-files-row-hover-color); } .hm-sw-recent-files__row:is(:hover, [data-hovered="true"]):not(:disabled) .hm-sw-recent-files__name { color: var(--hm-sw-recent-files-row-hover-color); }
.hm-sw-recent-files__empty { padding: 16px 45px; color: var(--hm-sw-recent-files-path-color); font-size: var(--hm-sw-header-label-size); opacity: 0.6; } .hm-sw-recent-files__empty { padding: 16px 45px; color: var(--hm-sw-recent-files-path-color); font-size: var(--hm-sw-header-label-size); opacity: 0.6; }
@@ -471,6 +477,14 @@
font-size: var(--hm-sw-header-font-size); font-size: var(--hm-sw-header-font-size);
line-height: 18px; line-height: 18px;
letter-spacing: 0.02em; letter-spacing: 0.02em;
/* 2026-10-21: path + file name on one line; a long path is cut at its start (…) so the file name stays visible.
Right-to-left only for the ellipsis side; the text is an isolated left-to-right span (dir="ltr"), centered. */
direction: rtl;
text-align: center;
} }
.hm-sw-recent-files__preview-body { position: relative; width: 100%; height: 243px; overflow: hidden; border-radius: 0 0 4px 0; } .hm-sw-recent-files__preview-body { position: relative; width: 100%; height: 243px; overflow: hidden; border-radius: 0 0 4px 0; background: var(--hm-sw-recent-files-preview-body-background); }
/* 2026-10-21 design: no image in the preview; the body shows the "no preview" prohibition sign, centered and sized to
the 243px body (no image file: inline SVG drawn with CSS strokes). */
.hm-sw-recent-files__preview-body { display: grid; place-items: center; }
.hm-sw-recent-files__preview-body > .hm-sw-recent-files__preview-none { display: block; width: 200px; height: 200px; fill: none; stroke: var(--hm-sw-recent-files-preview-none-color); stroke-width: 11; }
.hm-sw-recent-files__preview-body > :is(img, svg, canvas) { display: block; width: 100%; height: 100%; object-fit: cover; } .hm-sw-recent-files__preview-body > :is(img, svg, canvas) { display: block; width: 100%; height: 100%; object-fit: cover; }
+6
View File
@@ -210,6 +210,11 @@ button.hm-sw-viewer-header__brand { text-align: left; }
calc(100% + 2 * var(--hm-sw-header-document-menu-border-width)) calc(100% + 2 * var(--hm-sw-header-document-menu-border-width))
); );
} }
/* 2026-10-21 user request: viewer file rows show the name only (no icon, no icon gap); rows, hover and the current
file color are unchanged. The SWHeader document list keeps its icons. */
.hm-sw-viewer-header__document[data-list] .hm-sw-header__document-icon { display: none; }
/* SWHeader minimize / close (SWHeaderWindowControls) at the end of `trailing`: right edge as in SWHeader, no inset. */
.hm-sw-viewer-header__object:has(> .hm-sw-viewer-header__actions > .hm-sw-header__window-buttons:last-child) { padding-right: 0; }
/* Viewer header dropdown colors (2026-10-21): the menus of the header — menu buttons, More and their submenus — use the /* Viewer header dropdown colors (2026-10-21): the menus of the header — menu buttons, More and their submenus — use the
colors of the SWHeader `...` menu (sw.css .hm-sw-header__more-menu): its face and shadow (fixed values there), the colors of the SWHeader `...` menu (sw.css .hm-sw-header__more-menu): its face and shadow (fixed values there), the
object border token, the header text, secondary text at 80% (as its values) and the white 8% row hover with the text object border token, the header text, secondary text at 80% (as its values) and the white 8% row hover with the text
@@ -569,6 +574,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
} }
/* SWSelect inside viewer panels reads the viewer panel tokens (same values; viewer theme overrides follow). */ /* SWSelect inside viewer panels reads the viewer panel tokens (same values; viewer theme overrides follow). */
.hm-sw-viewer-panel { --hm-sw-select-border-color: var(--hm-sw-viewer-panel-inner-border); --hm-sw-select-background: var(--hm-sw-viewer-panel-field-background); --hm-sw-select-text: var(--hm-sw-viewer-panel-text); } .hm-sw-viewer-panel { --hm-sw-select-border-color: var(--hm-sw-viewer-panel-inner-border); --hm-sw-select-background: var(--hm-sw-viewer-panel-field-background); --hm-sw-select-text: var(--hm-sw-viewer-panel-text); }
.hm-sw-property.hm-sw-viewer-panel .hm-sw-select:not(:disabled) { color: var(--hm-sw-select-text); }
/* Viewer rows dim a disabled field as a whole (SWViewerField 50%); the slider keeps its own opacity as before. */ /* Viewer rows dim a disabled field as a whole (SWViewerField 50%); the slider keeps its own opacity as before. */
.hm-sw-theme .hm-sw-viewer-slider:disabled { opacity: 1; } .hm-sw-theme .hm-sw-viewer-slider:disabled { opacity: 1; }
+134 -32
View File
@@ -240,6 +240,10 @@
.hm-sw-property-bar__field { display: flex; align-items: center; gap: 6px; white-space: nowrap; } .hm-sw-property-bar__field { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.hm-sw-property-bar__field label { color: var(--hm-sw-header-text-color); letter-spacing: .04em; } .hm-sw-property-bar__field label { color: var(--hm-sw-header-text-color); letter-spacing: .04em; }
.hm-sw-property-bar__field .hm-sw-header__input { flex-shrink: 0; } .hm-sw-property-bar__field .hm-sw-header__input { flex-shrink: 0; }
/* 2026-10-21 (user decision): the bar shows its values (반지름 · 지름 · 원주 · 면적 …) as plain text (an <output>, never
focused) in the input's place, font and outline. They stay red: the property window edits them. */
.hm-sw-property-bar__value { flex-shrink: 0; padding: 2px 4px 2px 6px; color: var(--hm-sw-header-input-color); font-size: var(--hm-sw-header-font-size); font-weight: 700; line-height: 16px; letter-spacing: 0.02em; white-space: nowrap; text-shadow: var(--hm-sw-header-text-outline); }
.hm-sw-property-bar__value[data-disabled="true"] { opacity: 0.4; }
.hm-sw-property-bar:focus-visible { outline: 2px solid var(--hm-sw-header-focus-color); outline-offset: -2px; } .hm-sw-property-bar:focus-visible { outline: 2px solid var(--hm-sw-header-focus-color); outline-offset: -2px; }
/* Configured controls use the same components in an ordered list; no empty removed slots. /* Configured controls use the same components in an ordered list; no empty removed slots.
@@ -1602,6 +1606,10 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
/* SWSelect: native select, panel field look; focus = the SW popup focus ring. */ /* SWSelect: native select, panel field look; focus = the SW popup focus ring. */
.hm-sw-select { flex: 0 0 auto; height: 28px; padding: 0 8px; border: 1px solid var(--hm-sw-select-border-color); border-radius: 4px; background: var(--hm-sw-select-background); color: var(--hm-sw-select-text); font: inherit; font-size: 12px; } .hm-sw-select { flex: 0 0 auto; height: 28px; padding: 0 8px; border: 1px solid var(--hm-sw-select-border-color); border-radius: 4px; background: var(--hm-sw-select-background); color: var(--hm-sw-select-text); font: inherit; font-size: 12px; }
.hm-sw-select:disabled { opacity: 0.4; cursor: default; } .hm-sw-select:disabled { opacity: 0.4; cursor: default; }
/* 2026-10-21: in an SW popup the chosen value of an enabled select is editable, so it is red like every editable value;
a disabled select and the option list keep --hm-sw-select-text. The viewer panels keep their own text color (sw-viewer.css). */
.hm-sw-property .hm-sw-select:not(:disabled) { color: var(--hm-sw-header-input-color); }
.hm-sw-property .hm-sw-select option { color: var(--hm-sw-select-text); }
.hm-sw-property__unit { flex: 0 0 auto; font-size: 12px; white-space: nowrap; } .hm-sw-property__unit { flex: 0 0 auto; font-size: 12px; white-space: nowrap; }
/* Color preview (26:12175): 70 x 16 black tile (1px padding, radius 2), 68 x 14 swatch with a white 1px border. */ /* Color preview (26:12175): 70 x 16 black tile (1px padding, radius 2), 68 x 14 swatch with a white 1px border. */
.hm-sw-property__swatch-area { display: flex; align-items: center; flex: 0 0 auto; } .hm-sw-property__swatch-area { display: flex; align-items: center; flex: 0 0 auto; }
@@ -1654,6 +1662,12 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-property__expand[aria-expanded="true"] > img { transform: rotate(180deg); } .hm-sw-property__expand[aria-expanded="true"] > img { transform: rotate(180deg); }
.hm-sw-property__counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); } .hm-sw-property__counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.hm-sw-property__counts > span { min-height: 28px; padding: 5px 0; border-bottom: 1px solid rgba(0,0,0,.5); font-weight: 700; } .hm-sw-property__counts > span { min-height: 28px; padding: 5px 0; border-bottom: 1px solid rgba(0,0,0,.5); font-weight: 700; }
/* 2026-10-21 design review: each object type entry (원 1, 폴리선 10 …) changes its own text color on Hover, with the
SW row accent #FF5C00 (the header list row hover color). Display only: no click, selection or filter. */
.hm-sw-property__counts > span:hover { color: var(--hm-sw-header-layer-row-hover-color); }
/* 2026-10-21 user request: Popup items whose color changes on Hover show the pointer (display-only entries too: no
click is added). Inputs and read-only values keep their own cursor. */
:is(.hm-sw-property__tool, .hm-sw-property__close, .hm-sw-property__expand, .hm-sw-property__counts > span) { cursor: pointer; }
.hm-sw-property__group--circle .hm-sw-property__group-content { position: relative; min-height: 260px; } .hm-sw-property__group--circle .hm-sw-property__group-content { position: relative; min-height: 260px; }
.hm-sw-property__group--circle .hm-sw-property__editors { position: relative; min-height: 260px; padding-top: 260px; } .hm-sw-property__group--circle .hm-sw-property__editors { position: relative; min-height: 260px; padding-top: 260px; }
/* Shape slot (Figma 26:11932 / 26:12035): 208 x 208 at (39, 26) of the 260px shape group. Default content is /* Shape slot (Figma 26:11932 / 26:12035): 208 x 208 at (39, 26) of the 260px shape group. Default content is
@@ -1664,6 +1678,8 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-property__group--circle .hm-sw-property__editor { position: absolute; } .hm-sw-property__group--circle .hm-sw-property__editor { position: absolute; }
.hm-sw-property__group--circle .hm-sw-property__row { padding: 0; min-height: 24px; gap: 4px; } .hm-sw-property__group--circle .hm-sw-property__row { padding: 0; min-height: 24px; gap: 4px; }
.hm-sw-property__group--circle .hm-sw-property__row > label { flex-basis: auto; } .hm-sw-property__group--circle .hm-sw-property__row > label { flex-basis: auto; }
/* 2026-10-21 user request: 원주 / 지름 / 반지름 / 면적 = [Input] — a text "=" after the name, outside the input. */
.hm-sw-property__editor[data-slot] .hm-sw-property__row > label::after { content: " =" / ""; }
.hm-sw-property__group--circle .hm-sw-property__value { flex: 0 1 auto; } .hm-sw-property__group--circle .hm-sw-property__value { flex: 0 1 auto; }
.hm-sw-property__group--circle .hm-sw-header__input { width: calc(var(--hm-sw-input-chars) * 1ch + 8px); flex: 0 1 auto; } .hm-sw-property__group--circle .hm-sw-header__input { width: calc(var(--hm-sw-input-chars) * 1ch + 8px); flex: 0 1 auto; }
.hm-sw-property__editor[data-slot="circumference"] { left: 113px; top: 0; } .hm-sw-property__editor[data-slot="circumference"] { left: 113px; top: 0; }
@@ -1749,6 +1765,46 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
text-shadow: var(--hm-sw-header-text-outline); text-shadow: var(--hm-sw-header-text-outline);
} }
/* Slider value box (2026-10-21, 투명도): value + annotation in one field that fills the room before the gauge; the
same Default / Hover / On (#0F0D0A, white annotation) as the number box. */
.hm-sw-property__slider-value {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
flex: 1 1 auto;
min-width: 0;
height: 28px;
padding: 0 7px;
border: 1px solid transparent;
border-radius: 2px;
}
.hm-sw-property__row:not([data-disabled]) .hm-sw-property__slider-value:is(:hover, :not([data-readonly]):focus-within) { border-color: var(--hm-sw-property-field-hover-border-color); }
.hm-sw-property__slider-value > .hm-sw-header__input,
.hm-sw-property__slider-value > .hm-sw-header__input:is(:hover, :focus) {
flex: 1 1 auto;
align-self: stretch;
width: auto;
min-width: 0;
height: auto;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
font-weight: 500;
letter-spacing: 0.02em;
text-shadow: none;
}
.hm-sw-property__slider-value > .hm-sw-header__input:not(:read-only):focus-visible { outline: none; }
.hm-sw-property__slider-value[data-on="edit"]:focus-within { background: var(--hm-sw-property-field-editing-background); }
.hm-sw-property__slider-value[data-on="edit"]:focus-within > .hm-sw-header__input { text-shadow: var(--hm-sw-header-text-outline); }
.hm-sw-property__slider-value[data-on="edit"]:focus-within > .hm-sw-property__unit { color: var(--hm-sw-property-field-editing-unit-color); }
/* Value color rule (2026-10-21): red (#E60403) marks an editable value only. A read-only, computed or disabled value
is the black label color of the light popup; a disabled row also keeps its 40%. */
:is(.hm-sw-property__value, .hm-sw-property__slider-value)[data-readonly] :is(.hm-sw-header__input, .hm-sw-property__trigger, select:not(.hm-sw-select)) { color: var(--hm-sw-property-label); }
/* Dropdown fields: layer (pl/pr 8), line style (pl 8, pr 6: value + (ByL) + pattern), color (RGB | swatch). */ /* Dropdown fields: layer (pl/pr 8), line style (pl 8, pr 6: value + (ByL) + pattern), color (RGB | swatch). */
.hm-sw-property__trigger { .hm-sw-property__trigger {
display: flex; display: flex;
@@ -1808,40 +1864,72 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
} }
/* Responsive — header width (container query); the header never scrolls horizontally. /* Responsive — header width (container query); the header never scrolls horizontally.
Fields keep their Figma widths at every tier (measured Noto Sans KR content widths are From 760px up (2026-10-21 user decision; the SW Footer story widths) nothing moves into More: the five menus 레이어 · 색상 · 선스타일 ·
231 / 168 / 278 / 142 / 163px, so shrinking would only truncate values). Tools move into 문자스타일 · 치수스타일 always stay in the header with their titles, and the More button is not shown. As it narrows:
More instead, so every Prop and Callback stays reachable. Breakpoints come from the
measured widths: fixed parts (home 63 + gaps 6 + window 240) + object area + file name.
>= 1800 Figma layout. 1920 -> 1800 only the file name shrinks (310 -> 190). >= 1800 Figma layout. 1920 -> 1800 only the file name shrinks (310 -> 190).
< 1800 선/문자/치수 스타일 -> More (object area 1301 -> 719) < 1800 the 그리기모드 text hides, the line-type / dimension pictures hide and the color chip is 24px (fields take
< 1180 그리기모드 label hides (719 -> 633) their content width); the file name gives way first (down to 96px, ellipsis), then the field values
< 1080 속성Bar/속성창 -> More, window move 88 -> 24 (633 -> 480, window 240 -> 176) shrink with an ellipsis, each by its own width.
< 900 색상 -> More (480 -> 284) < 1080 window move area 88 -> 24 (window 240 -> 176); layer-state icons, the color chip and the 문자스타일
< 640 인쇄/팝업 가이드 -> More, window move hides (window 176 -> 64) font name hide, field insets 6px.
Only below ~520px (phones) does the layer field shrink: its state icons hide and the <= 900 the 선스타일 field shows 선스타일 only (가중치 / 스케일 hide).
layer name ellipsizes. Below 760px (phones, e.g. 390) the former tiers are unchanged: parts move into More (선/문자/치수 스타일, 그리기모드,
The file name always takes the remaining width (ellipsis). cqw = header width. 속성Bar/속성창, 색상, then 인쇄/팝업 가이드 below 640), and below 520 the layer-state icons hide.
Hiding targets the flex item of each field ([data-menu] / [data-tier] on the field button, or on Each field keeps its button, title, value, dropdown and hover / focus box. cqw = header width.
the wrapper of a field with a dropdown), so a dropdown field hides like a plain one. */ 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 - var(--hm-sw-header-leading-width, 0px)), 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--layer { flex-shrink: 1; }
.hm-sw-header__field:not(.hm-sw-header__field--layer) { flex-shrink: 0; } .hm-sw-header__field:not(.hm-sw-header__field--layer) { flex-shrink: 0; }
@container hm-sw-header (max-width: 1799.98px) { @container hm-sw-header (min-width: 760px) and (max-width: 1799.98px) {
.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 - 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__mode { display: none; }
.hm-sw-header__fields { padding-left: 4px; } .hm-sw-header__fields { padding-left: 4px; }
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 944px - var(--hm-sw-header-leading-width, 0px)), 310px); } /* Content width (no Figma box) that may shrink: the free room is taken from each value by its own width. */
.hm-sw-header__fields > :is(.hm-sw-header__layer, .hm-sw-header__menu),
.hm-sw-header__fields > .hm-sw-header__control > :is(.hm-sw-header__layer, .hm-sw-header__menu) { flex: 0 1 auto; width: auto; min-width: 0; }
.hm-sw-header__fields .hm-sw-header__field { flex: 0 1 auto; width: auto; min-width: 0; }
/* Fixed-size buttons in a configured bar (인쇄 · K) keep their box: only the fields give way. */
.hm-sw-header__fields > .hm-sw-header__control:is([data-control="print"], [data-control="popupGuide"]) { flex-shrink: 0; }
.hm-sw-header__layer > .hm-sw-header__field--layer { width: 100%; }
.hm-sw-header__field :is(.hm-sw-header__line-values, .hm-sw-header__font-name) { min-width: 0; }
/* Tighter fields: smaller insets, a 24px color chip, no line-type / dimension pictures (the value names stay). */
.hm-sw-header__fields .hm-sw-header__field { padding: 0 8px; gap: 8px; }
.hm-sw-header__fields .hm-sw-header__chip { flex-basis: 24px; width: 24px; }
.hm-sw-header__fields :is(.hm-sw-header__linetype, .hm-sw-header__dimension-icon) { display: none; }
.hm-sw-header__fields .hm-sw-header__line-values { gap: 8px; }
/* Open dropdown (2026-10-21 user request): the fields are narrower than their lists here, so the yellow ring stays
inside each button and the list gets its own ring, as a shifted list (치수스타일) already does. */
.hm-sw-header__fields :is(.hm-sw-header__layer-menu, .hm-sw-header__menu-panel:not(.hm-sw-header__line-menu))::before { top: -1px; }
.hm-sw-header__fields .hm-sw-header__field[data-expanded="true"]::after { opacity: 1; }
/* Where the list meets the button the seam is black across the button width (as the 선스타일 seam). */
.hm-sw-header__fields :is(.hm-sw-header__layer, .hm-sw-header__menu:not(.hm-sw-header__menu--line-style)):has(> .hm-sw-header__field[data-expanded="true"])::after {
content: "";
position: absolute;
z-index: 31;
top: calc(100% - 1px);
left: 0;
right: 0;
height: 2px;
background: var(--hm-sw-header-layer-menu-border-color);
pointer-events: none;
}
/* 1610 - 1301 + the tighter object area with titles, without 그리기모드 (891, measured) = 1200. */
.hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 1200px - var(--hm-sw-header-leading-width, 0px)), 310px); }
}
@container hm-sw-header (min-width: 760px) and (max-width: 1079.98px) {
/* Values need the room (1024px, 2026-10-21 user request): the layer-state icons, the color chip and the font name
beside 문자스타일 hide, insets 6px, every value part may shrink (ellipsis). */
.hm-sw-header__fields .hm-sw-header__field { padding: 0 6px; gap: 6px; }
.hm-sw-header__fields :is(.hm-sw-header__field--layer .hm-sw-header__layer-icons, .hm-sw-header__chip, .hm-sw-header__font-name) { display: none; }
.hm-sw-header__fields .hm-sw-header__line-values { grid-template-columns: minmax(0, auto) minmax(0, 38px) minmax(0, 38px); gap: 6px; }
}
@container hm-sw-header (min-width: 760px) and (max-width: 900px) {
/* 900px and narrower (2026-10-21 user request): the 선스타일 field keeps only 선스타일 (가중치 / 스케일 hide). */
.hm-sw-header__fields .hm-sw-header__line-values { grid-template-columns: minmax(0, auto); }
.hm-sw-header__fields .hm-sw-header__line-values > :not(:first-child) { display: none; }
} }
@container hm-sw-header (max-width: 1079.98px) { @container hm-sw-header (max-width: 1079.98px) {
.hm-sw-header__properties { display: none; }
.hm-sw-header__more { margin-left: auto; margin-right: 4px; }
.hm-sw-header__more-item[data-tier="panel"] { display: flex; }
.hm-sw-header__motion { flex-basis: 24px; background-size: 18px 37px; } .hm-sw-header__motion { flex-basis: 24px; background-size: 18px 37px; }
/* 24px move area: no room for the icon motion, so hover shows only the tooltip, right of the /* 24px move area: no room for the icon motion, so hover shows only the tooltip, right of the
area like Figma hover2 (x = area width + 1, y 11). */ area like Figma hover2 (x = area width + 1, y 11). */
@@ -1851,9 +1939,17 @@ 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-tooltip { left: calc(100% + 1px); top: 11px; }
.hm-sw-header__motion:hover > .hm-sw-header__motion-tooltip { animation: none; opacity: 1; } .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__window { flex-basis: 176px; width: 176px; }
.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) { /* Below 760px: the former tiers (선/문자/치수 스타일 < 1800, 그리기모드 < 1180, 속성Bar/속성창 < 1080, 색상 < 900). */
@container hm-sw-header (max-width: 759.98px) {
.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__mode { display: none; }
.hm-sw-header__fields { padding-left: 4px; }
.hm-sw-header__properties { display: none; }
.hm-sw-header__more { margin-left: auto; margin-right: 4px; }
.hm-sw-header__more-item[data-tier="panel"] { display: flex; }
.hm-sw-header__fields > [data-menu="color"] { display: none; } .hm-sw-header__fields > [data-menu="color"] { display: none; }
.hm-sw-header__more-item[data-tier="narrow"] { display: flex; } .hm-sw-header__more-item[data-tier="narrow"] { display: flex; }
.hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 529px - var(--hm-sw-header-leading-width, 0px)), 310px); } .hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 529px - var(--hm-sw-header-leading-width, 0px)), 310px); }
@@ -1960,17 +2056,23 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
:is(.hm-sw-header__leading, .hm-sw-header__document[data-tool]) + .hm-sw-header__document > .hm-sw-header__document-button { padding-left: 12px; } :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 /* 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 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). */ the existing behavior (file name first, then the layer field below 520px). Below 760px only (2026-10-21): from
.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; } 760px up nothing moves into More, so the fields shrink beside Open like everywhere else. */
@container hm-sw-header (max-width: 759.98px) {
.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; } .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 /* 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). */ 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__properties.hm-sw-tool__group { display: flex; }
.hm-sw-header__actions.hm-sw-tool__pill { 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, /* 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). */ 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"]), Below 760px only (2026-10-21), as above. */
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control:not([data-control="document"]) > * { flex-shrink: 0; } @container hm-sw-header (max-width: 759.98px) {
.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). */ /* ...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; } .hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control[data-control="document"] { min-width: 96px; }
@@ -227,8 +227,9 @@ try {
await page.screenshot({ path: path.join(out, 'composed-property-panel.png'), fullPage: true }) await page.screenshot({ path: path.join(out, 'composed-property-panel.png'), fullPage: true })
const radius = page.getByRole('dialog').getByRole('textbox', { name: '반지름', exact: true }) const radius = page.getByRole('dialog').getByRole('textbox', { name: '반지름', exact: true })
await radius.fill('12.5'); await radius.press('Enter') await radius.fill('12.5'); await radius.press('Enter')
await page.waitForFunction(() => document.querySelector('.hm-sw-property-bar [data-property-id="radius"] input')?.value === '12.5') // 2026-10-21 (user decision): the Bar shows the value as read-only text (<output>).
const barRadius = await page.getByRole('region', { name: '속성Bar', exact: true }).getByRole('textbox', { name: '반지름', exact: true }).inputValue() await page.waitForFunction(() => document.querySelector('.hm-sw-property-bar [data-property-id="radius"] output')?.textContent === '12.5')
const barRadius = await page.getByRole('region', { name: '속성Bar', exact: true }).locator('[data-property-id="radius"] output').textContent()
check('property Bar and panel share edited state', Number(barRadius) === 12.5, barRadius) check('property Bar and panel share edited state', Number(barRadius) === 12.5, barRadius)
const panelWeight = page.getByRole('dialog').getByRole('textbox', { name: '선가중치', exact: true }) const panelWeight = page.getByRole('dialog').getByRole('textbox', { name: '선가중치', exact: true })
await panelWeight.fill('0.75'); await panelWeight.press('Enter') await panelWeight.fill('0.75'); await panelWeight.press('Enter')
@@ -147,7 +147,10 @@ const viewerSel = page.locator('.hm-sw-viewer-panel [data-sw-select="unit"]')
const vs = await selStates(viewerSel) const vs = await selStates(viewerSel)
const viewerOptions = await viewerSel.evaluate(e => [...e.options].map(o => o.value)) const viewerOptions = await viewerSel.evaluate(e => [...e.options].map(o => o.value))
await viewerSel.selectOption('m'); await page.waitForTimeout(80) await viewerSel.selectOption('m'); await page.waitForTimeout(80)
check('Select: Popup 도면 설정 단위 = Viewer 축척 지정 단위 (default · hover · focus computed look)', ps.d === vs.d && ps.h === vs.h && ps.f === vs.f && ps.d.includes('class=hm-sw-select'), { ps, vs }) // 2026-10-21 (user decision): the chosen value of an editable Popup select is red (#E60403) like every editable value;
// the Viewer panel keeps its own text color. Everything else of the look stays the same.
const noColor = look => look.replace(/(^|;)color=[^;]*/, '$1')
check('Select: Popup 도면 설정 단위 = Viewer 축척 지정 단위 (default · hover · focus computed look, text color apart); Popup value red', noColor(ps.d) === noColor(vs.d) && noColor(ps.h) === noColor(vs.h) && noColor(ps.f) === noColor(vs.f) && ps.d.includes('class=hm-sw-select') && ps.d.includes('color=rgb(230, 4, 3)'), { ps, vs })
check('Select: options and selection kept (Popup mm/cm/m, Viewer mm/m), change events', JSON.stringify(popupOptions) === '["mm","cm","m"]' && JSON.stringify(viewerOptions) === '["mm","m"]' && popupPicked[0] && popupPicked[1] === 'm' && await viewerSel.inputValue() === 'm', { popupOptions, viewerOptions, popupPicked }) check('Select: options and selection kept (Popup mm/cm/m, Viewer mm/m), change events', JSON.stringify(popupOptions) === '["mm","cm","m"]' && JSON.stringify(viewerOptions) === '["mm","m"]' && popupPicked[0] && popupPicked[1] === 'm' && await viewerSel.inputValue() === 'm', { popupOptions, viewerOptions, popupPicked })
await page.locator('.hm-sw-viewer-panel', { hasText: '축척 지정' }).screenshot({ path: path.join(out, 'viewer-scale-select.png') }) await page.locator('.hm-sw-viewer-panel', { hasText: '축척 지정' }).screenshot({ path: path.join(out, 'viewer-scale-select.png') })
@@ -160,8 +163,10 @@ const flips = grow.slice(1).filter((s, i) => s[1] !== grow[i][1]).length
check('Header: Open returns once while growing 520 → 1080 and stays', grow[0][1] === 0 && grow.at(-1)[1] === 1 && flips === 1, grow) check('Header: Open returns once while growing 520 → 1080 and stays', grow[0][1] === 0 && grow.at(-1)[1] === 1 && flips === 1, grow)
const wide = await walk([1440, 1920]) const wide = await walk([1440, 1920])
check('Header: at 1920 every part is back and Open is shown', wide.at(-1)[1] === 1 && wide.at(-1)[2] === '', wide) check('Header: at 1920 every part is back and Open is shown', wide.at(-1)[1] === 1 && wide.at(-1)[2] === '', wide)
const shrink = await walk([1800, 1440, 1080, 900, 640, 520]) // 2026-10-21 (user decision): from 760px up nothing moves into More (the fields shrink instead), so Open stays shown
check('Header: shrinking, Open moves into More first (1800) and stays there', shrink[0][1] === 0 && shrink.every(s => s[1] === 0), shrink) // down to 760; below it the measured More takes Open as before.
const shrink = await walk([1800, 1440, 1080, 900, 760, 640, 520])
check('Header: shrinking, Open stays next to the logo down to 760 (no More), then moves into More and stays there', shrink.every(s => s[0] < 760 || (s[1] === 1 && s[2] === '')) && shrink.at(-1)[1] === 0 && shrink.filter(s => s[0] < 760).every((s, i, a) => i === 0 || s[1] <= a[i - 1][1]), shrink)
const regrow = await walk([560, 600, 640, 680, 720, 900, 1080, 1920]) const regrow = await walk([560, 600, 640, 680, 720, 900, 1080, 1920])
const reflips = regrow.slice(1).filter((s, i) => s[1] !== regrow[i][1]).length const reflips = regrow.slice(1).filter((s, i) => s[1] !== regrow[i][1]).length
check('Header: regrowing after a shrink, Open returns once and stays', regrow.at(-1)[1] === 1 && reflips <= 1 && regrow.at(-1)[2] === '', regrow) check('Header: regrowing after a shrink, Open returns once and stays', regrow.at(-1)[1] === 1 && reflips <= 1 && regrow.at(-1)[2] === '', regrow)
@@ -82,6 +82,8 @@ if (sections.includes('options')) {
headerRight: header.getBoundingClientRect().right, barRight: Math.max(...parts.map(r => r.right)), overlap, 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, 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', more: !!header.querySelector('.hm-sw-header__more') && getComputedStyle(header.querySelector('.hm-sw-header__more')).display !== 'none',
menus: [...header.querySelectorAll('.hm-sw-header__fields .hm-sw-header__field[data-menu]')].filter(e => e.getClientRects().length && getComputedStyle(e).display !== 'none').map(e => e.dataset.menu),
titles: [...header.querySelectorAll('.hm-sw-header__fields .hm-sw-header__field[data-menu]')].filter(e => e.getClientRects().length && getComputedStyle(e).display !== 'none' && [...e.querySelectorAll('.hm-sw-header__field-label')].some(l => l.getClientRects().length && getComputedStyle(l).display !== 'none')).length,
} }
}) })
let inMore = null let inMore = null
@@ -94,7 +96,11 @@ if (sections.includes('options')) {
widths.push({ width, ...state, inMore }) 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}: 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 }) 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) // 2026-10-21 (user decision): from 760px up nothing moves into More; the fields shrink beside Open (fluid widths,
// titles and values ellipsize). The Figma layer width 234 holds where there is room (1920); narrower it is fluid.
if (width >= 760) check(`responsive ${width}: Open shown, no More, the five menus stay in the header with their titles`, state.open > 0 && !state.more && JSON.stringify(state.menus) === '["layer","color","line-style","text-style","dimension-style"]' && state.titles === 5, state)
if (width >= 1920) check(`responsive ${width}: layer field keeps its Figma width (234)`, Math.abs(state.layer - 234) < 0.6, state.layer)
else if (width >= 760) check(`responsive ${width}: layer field shrinks fluidly (0 < width <= 234)`, state.layer > 0 && state.layer <= 234.5, state.layer)
} }
report.data.responsive = widths report.data.responsive = widths
await page.locator('[data-responsive-open]').screenshot({ path: path.join(out, 'open-placement-responsive.png') }) await page.locator('[data-responsive-open]').screenshot({ path: path.join(out, 'open-placement-responsive.png') })
@@ -149,6 +155,7 @@ if (sections.includes('options') || sections.includes('composed')) {
}) })
rows.push({ width, ...state }) 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) 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 (width >= 760) check(`composed responsive ${width}: 파일 열기 shown, no More (from 760px)`, state.open > 0 && !state.more, state)
if (!state.open) { if (!state.open) {
await h.locator('.hm-sw-header__more-trigger').click() await h.locator('.hm-sw-header__more-trigger').click()
const entry = h.locator('.hm-sw-header__more-item', { hasText: '파일 열기' }) const entry = h.locator('.hm-sw-header__more-item', { hasText: '파일 열기' })
@@ -74,10 +74,13 @@ export async function popupChecks({ page, goto, check, log, rect, out, report })
await page.locator('[data-stack]').screenshot({ path: path.join(out, 'popup-short-dropdown.png') }) await page.locator('[data-stack]').screenshot({ path: path.join(out, 'popup-short-dropdown.png') })
await page.keyboard.press('Escape') 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) 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 값') // 2026-10-21 (user decision): Popup A's value is read-only plain text (no input, no red, no focus ring). The Story has
await aInput.click(); await aInput.fill('A2'); await aInput.press('Escape') // no editable input any more (A's was the only one): Escape on an edited input is covered by property-fields /
check('several popups: Escape on an edited input cancels the edit only', await aInput.inputValue() === 'A' && await popup('a').count() === 1) // overview; here Escape inside A is pressed on its close button.
await aInput.press('Escape') const aValue = await popup('a').locator('[data-readonly-value="a"]').evaluate(e => ({ tag: e.tagName, tab: e.tabIndex, inputs: e.closest('[data-popup]').querySelectorAll('.hm-sw-property__body input').length, color: getComputedStyle(e).color }))
check('several popups: Popup A shows its read-only value as plain text (not focusable, not red)', aValue.tag === 'SPAN' && aValue.tab === -1 && aValue.inputs === 0 && aValue.color !== 'rgb(255, 0, 0)', aValue)
await popup('a').locator('button').first().focus()
await page.keyboard.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()) 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.locator('body').click({ position: { x: 5, y: 5 } })
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
@@ -101,6 +101,11 @@ try {
await pointerdown('body') await pointerdown('body')
check('Document title: outside pointerdown closes the list, focus kept', documentOpen && await documentButton.getAttribute('aria-expanded') === 'false' && await documentButton.evaluate(e => e === document.activeElement), { documentOpen }) check('Document title: outside pointerdown closes the list, focus kept', documentOpen && await documentButton.getAttribute('aria-expanded') === 'false' && await documentButton.evaluate(e => e === document.activeElement), { documentOpen })
await story('sw-header--responsive-more') await story('sw-header--responsive-more')
// 2026-10-21 (user decision): the Story widths are 1920 … 760 and from 760px up the header shows no More. More stays
// the narrow-header behavior (< 760), so one frame is narrowed to 640px to dismiss it.
check('More: not shown at the Story widths (760px and up)', await page.locator('.hm-sw-header__more-trigger:visible').count() === 0)
await page.locator('.sw-story-frame').first().evaluate(e => { e.style.width = '640px' })
await page.waitForTimeout(250)
const more = page.locator('.hm-sw-header__more-trigger:visible').first() const more = page.locator('.hm-sw-header__more-trigger:visible').first()
await more.click() await more.click()
const moreOpen = await more.getAttribute('aria-expanded') === 'true' const moreOpen = await more.getAttribute('aria-expanded') === 'true'
+12 -11
View File
@@ -132,7 +132,9 @@ try {
if(await b.isVisible())await b.click() if(await b.isVisible())await b.click()
else{await header.getByRole('button',{name:'더보기',exact:true}).click();await header.getByRole('menuitemcheckbox',{name:'속성창',exact:true}).click()} else{await header.getByRole('button',{name:'더보기',exact:true}).click();await header.getByRole('menuitemcheckbox',{name:'속성창',exact:true}).click()}
} }
const barInput=id=>page.locator(`.hm-sw-property-bar [data-property-id="${id}"] input`) // 2026-10-21 (user decision): the Bar values (반지름 / 지름 / 원주 / 면적) are read-only red text (<output>, edited in the panel), never focused.
const barValue=id=>page.locator(`.hm-sw-property-bar [data-property-id="${id}"] output`)
const waitText=(id,value)=>page.waitForFunction(([id,value])=>document.querySelector(`.hm-sw-property-bar [data-property-id="${id}"] output`)?.textContent===value,[id,value])
const panelInput=id=>page.locator(`.hm-sw-property [data-property-id="${id}"] input`).first() const panelInput=id=>page.locator(`.hm-sw-property [data-property-id="${id}"] input`).first()
const waitValue=async(locator,value)=>page.waitForFunction(([element,value])=>element.value===value,[await locator.elementHandle(),value]) const waitValue=async(locator,value)=>page.waitForFunction(([element,value])=>element.value===value,[await locator.elementHandle(),value])
check('one Header and no duplicate fixed Figma specimen',await header.count()===1&&await page.getByText('Figma 기준 1920 × 49',{exact:true}).count()===0) check('one Header and no duplicate fixed Figma specimen',await header.count()===1&&await page.getByText('Figma 기준 1920 × 49',{exact:true}).count()===0)
@@ -161,17 +163,16 @@ try {
await page.keyboard.press('Escape');check('Escape closes Header dropdown',await header.locator('.hm-sw-header__layer-panel').count()===0) await page.keyboard.press('Escape');check('Escape closes Header dropdown',await header.locator('.hm-sw-header__layer-panel').count()===0)
await open('layer','레이어');await page.locator('.sb-page-title').click();check('outside click closes Header dropdown',await header.locator('.hm-sw-header__layer-panel').count()===0) await open('layer','레이어');await page.locator('.sb-page-title').click();check('outside click closes Header dropdown',await header.locator('.hm-sw-header__layer-panel').count()===0)
await barInput('radius').fill('6.25');await barInput('radius').press('Enter') const barState=await page.locator('.hm-sw-property-bar').evaluate(bar=>({object:bar.querySelector('.hm-sw-property-bar__object').textContent,order:[...bar.querySelectorAll('[data-property-id]')].map(e=>e.dataset.propertyId),inputs:bar.querySelectorAll('input').length,focusable:[...bar.querySelectorAll('output')].filter(e=>e.tabIndex>=0).length,colors:[...new Set([...bar.querySelectorAll('output')].map(e=>getComputedStyle(e).color))]}))
check('Bar Enter commits state',await barInput('radius').inputValue()==='6.25'&&(await callback()).includes('onPropertyChange(radius, 6.25)')) check('Bar: object text 원, order 반지름 / 지름 / 원주 / 면적, read-only red text (edited in the panel) without input or focus stop',barState.object==='원'&&barState.order.join()==='radius,diameter,circumference,area'&&barState.inputs===0&&barState.focusable===0&&barState.colors.join()==='rgb(230, 4, 3)',barState)
await barInput('radius').fill('-1');await barInput('radius').press('Enter');check('Bar validates min=0',await barInput('radius').inputValue()==='6.25') await barValue('radius').click();check('Bar value click never focuses the value',await page.evaluate(()=>document.activeElement?.tagName!=='OUTPUT'&&document.activeElement?.tagName!=='INPUT'))
await barInput('radius').fill('9');await barInput('radius').press('Escape');check('Bar Escape cancels edit',await barInput('radius').inputValue()==='6.25')
await panelButton();await page.locator('.hm-sw-property').waitFor() await panelButton();await page.locator('.hm-sw-property').waitFor()
check('Header opens panel with same Bar value',await panelInput('radius').inputValue()==='6.25') check('Header opens panel with same Bar value',await panelInput('radius').inputValue()===await barValue('radius').textContent())
check('numeric weight property remains numeric after ByLayer mode',await panelInput('weight').inputValue()==='0.25') check('numeric weight property remains numeric after ByLayer mode',await panelInput('weight').inputValue()==='0.25')
check('Header layer value shared with panel',(await page.locator('.hm-sw-property [data-property-id="layer"]').textContent()).includes('CC-D GNL')) check('Header layer value shared with panel',(await page.locator('.hm-sw-property [data-property-id="layer"]').textContent()).includes('CC-D GNL'))
await panelInput('radius').fill('7.5');await panelInput('radius').press('Enter') await panelInput('radius').fill('7.5');await panelInput('radius').press('Enter')
await waitValue(barInput('radius'),'7.5') await waitText('radius','7.5')
check('panel edit updates the Bar',await barInput('radius').inputValue()==='7.5') check('panel edit updates the Bar',await barValue('radius').textContent()==='7.5'&&(await callback()).includes('onPropertyChange(radius, 7.5)'))
await panelInput('radius').fill('-2');await panelInput('radius').press('Enter');check('panel number validation rejects negative radius',await panelInput('radius').inputValue()==='7.5') await panelInput('radius').fill('-2');await panelInput('radius').press('Enter');check('panel number validation rejects negative radius',await panelInput('radius').inputValue()==='7.5')
await page.locator('.hm-sw-property [data-property-id="layer"] button').click() await page.locator('.hm-sw-property [data-property-id="layer"] button').click()
const drop=page.locator('.hm-sw-property__drop') const drop=page.locator('.hm-sw-property__drop')
@@ -195,10 +196,10 @@ try {
await panelInput('radius').press('Escape');check('next Escape closes panel and restores Header focus',await page.locator('.hm-sw-property').count()===0&&await header.locator('[data-property-panel-trigger]').evaluate(e=>document.activeElement===e)) await panelInput('radius').press('Escape');check('next Escape closes panel and restores Header focus',await page.locator('.hm-sw-property').count()===0&&await header.locator('[data-property-panel-trigger]').evaluate(e=>document.activeElement===e))
await panelButton();await page.getByRole('button',{name:'속성창 닫기',exact:true}).click();check('panel Close button restores focus',await header.locator('[data-property-panel-trigger]').evaluate(e=>document.activeElement===e)) await panelButton();await page.getByRole('button',{name:'속성창 닫기',exact:true}).click();check('panel Close button restores focus',await header.locator('[data-property-panel-trigger]').evaluate(e=>document.activeElement===e))
await page.getByText('시연 데이터',{exact:true}).click() await page.getByText('시연 데이터',{exact:true}).click()
await page.getByRole('button',{name:'외부 값 12.5',exact:true}).click();await waitValue(barInput('radius'),'12.5');check('external Props update Bar',await barInput('radius').inputValue()==='12.5') await page.getByRole('button',{name:'외부 값 12.5',exact:true}).click();await waitText('radius','12.5');check('external Props update Bar',await barValue('radius').textContent()==='12.5')
await page.getByRole('checkbox',{name:'속성 ReadOnly',exact:true}).check();check('Bar ReadOnly',await barInput('radius').evaluate(e=>e.readOnly)) await page.getByRole('checkbox',{name:'속성 ReadOnly',exact:true}).check();check('Bar ReadOnly: text, no input',await barValue('radius').count()===1&&await page.locator('.hm-sw-property-bar input').count()===0)
await page.getByRole('checkbox',{name:'속성 ReadOnly',exact:true}).uncheck() await page.getByRole('checkbox',{name:'속성 ReadOnly',exact:true}).uncheck()
await page.getByRole('checkbox',{name:'속성 Disabled',exact:true}).check();check('Bar Disabled',await barInput('radius').isDisabled()) await page.getByRole('checkbox',{name:'속성 Disabled',exact:true}).check();check('Bar Disabled: dimmed text',await barValue('radius').getAttribute('data-disabled')==='true')
await page.getByRole('checkbox',{name:'속성 Disabled',exact:true}).uncheck() await page.getByRole('checkbox',{name:'속성 Disabled',exact:true}).uncheck()
await page.getByRole('button',{name:'필드 추가',exact:true}).click();check('dynamic Bar field added',await page.locator('.hm-sw-property-bar [data-property-id^="preview-extra-"]').count()===1) await page.getByRole('button',{name:'필드 추가',exact:true}).click();check('dynamic Bar field added',await page.locator('.hm-sw-property-bar [data-property-id^="preview-extra-"]').count()===1)
await panelButton();check('dynamic field shared with panel',await page.locator('.hm-sw-property [data-property-id^="preview-extra-"]').count()===1) await panelButton();check('dynamic field shared with panel',await page.locator('.hm-sw-property [data-property-id^="preview-extra-"]').count()===1)
@@ -127,7 +127,13 @@ try {
check('Hover leave resets live specimen', await motion.evaluate(e => e.getAnimations({ subtree: true }).length === 0)) check('Hover leave resets live specimen', await motion.evaluate(e => e.getAnimations({ subtree: true }).length === 0))
await goto('responsive-more') await goto('responsive-more')
const header = page.locator('[data-frame-width="1440"] header.hm-sw-header').first() // 2026-10-21 (user decision): no More from 760px up (Story widths 1920 … 760); More stays the narrow-header behavior,
// so the 1440 frame is narrowed to 640px to switch the representative field there.
const frame1440 = page.locator('[data-frame-width="1440"]').first()
check('Responsive More: no More at 1440 (760px and up)', await frame1440.locator('.hm-sw-header__more-trigger:visible').count() === 0)
await frame1440.evaluate(e => { e.style.width = '640px' })
await page.waitForTimeout(250)
const header = frame1440.locator('header.hm-sw-header').first()
await header.getByRole('button', { name: '더보기', exact: true }).click() await header.getByRole('button', { name: '더보기', exact: true }).click()
await header.locator('[role="menuitem"][data-menu="line-style"]').click() await header.locator('[role="menuitem"][data-menu="line-style"]').click()
check('Responsive More still switches the representative field and opens its dropdown', await header.locator('.hm-sw-header__line-menu').count() > 0 && await header.locator('.hm-sw-header__line-menu').isVisible()) check('Responsive More still switches the representative field and opens its dropdown', await header.locator('.hm-sw-header__line-menu').count() > 0 && await header.locator('.hm-sw-header__line-menu').isVisible())
@@ -98,6 +98,9 @@ export const approvedHeaderDeclarations = {
// comparison captions removed, Interactive States / Document Dropdown tagged verification. // comparison captions removed, Interactive States / Document Dropdown tagged verification.
Overview: '23d522c13627ed26ac3e4cbeaaa8359b34c60aab', ResponsiveMore: '23d522c13627ed26ac3e4cbeaaa8359b34c60aab', PropertyPanelComparePage: '23d522c13627ed26ac3e4cbeaaa8359b34c60aab', Overview: '23d522c13627ed26ac3e4cbeaaa8359b34c60aab', ResponsiveMore: '23d522c13627ed26ac3e4cbeaaa8359b34c60aab', PropertyPanelComparePage: '23d522c13627ed26ac3e4cbeaaa8359b34c60aab',
InteractiveStates: '23d522c13627ed26ac3e4cbeaaa8359b34c60aab', DocumentDropdown: '23d522c13627ed26ac3e4cbeaaa8359b34c60aab', InteractiveStates: '23d522c13627ed26ac3e4cbeaaa8359b34c60aab', DocumentDropdown: '23d522c13627ed26ac3e4cbeaaa8359b34c60aab',
// 2fc1b3e (2026-10-21 SW design review, user-approved): Overview passes the Bar title 원; ComposedDemo shows the Bar
// values read-only in the approved order. Responsive More widths 1920 … 760 (MORE_WIDTHS, a non-export).
Overview: '2fc1b3e1084840277898061f04844dcb05b9349c', ComposedDemo: '2fc1b3e1084840277898061f04844dcb05b9349c',
} }
export const movedHeaderDeclarations = { PropertyPanelCompare: ['src/sw/sw-property-panel.stories.tsx', '2b0e1a2262002aedb01c29c5d18ffae1219bf3ef'] } export const movedHeaderDeclarations = { PropertyPanelCompare: ['src/sw/sw-property-panel.stories.tsx', '2b0e1a2262002aedb01c29c5d18ffae1219bf3ef'] }
// 2026-10-20 (legacy suite alignment): dropdown-consolidation, storybook-cleanup-stage1, property-panel-comparison and // 2026-10-20 (legacy suite alignment): dropdown-consolidation, storybook-cleanup-stage1, property-panel-comparison and
@@ -145,6 +148,10 @@ export const approvedHeaderSourceChanges = {
// MeasuredMoreDemo removed), property-panel comparison captions removed). Listed last so it wins for repeated names. // MeasuredMoreDemo removed), property-panel comparison captions removed). Listed last so it wins for repeated names.
'23d522c13627ed26ac3e4cbeaaa8359b34c60aab': ['import:@hm-design/sw', 'Overview', 'InteractiveStates', 'DocumentDropdown', 'MORE_WIDTHS', 'ResponsiveMore', '23d522c13627ed26ac3e4cbeaaa8359b34c60aab': ['import:@hm-design/sw', 'Overview', 'InteractiveStates', 'DocumentDropdown', 'MORE_WIDTHS', 'ResponsiveMore',
'PROPERTY_STATES', 'PropertyCompare', 'PropertyPanelComparePage', 'StyleDemo', 'MeasuredMoreDemo'], 'PROPERTY_STATES', 'PropertyCompare', 'PropertyPanelComparePage', 'StyleDemo', 'MeasuredMoreDemo'],
// fix(sw): 2026-10-21 SW design review (user-approved): Overview Bar title, Responsive More 1920 / 1440 / 1280 / 950 /
// 860 / 760 (no More from 760px), property fixture (선꼴, editable 원주 / 면적, weight annotation), Bar fixture computed
// in the order 반지름 / 지름 / 원주 / 면적 with BAR_TITLE 원, ComposedDemo Bar read-only. Listed last so it wins.
'2fc1b3e1084840277898061f04844dcb05b9349c': ['Overview', 'ComposedDemo', 'MORE_WIDTHS', 'PROPERTY_FIXTURE', 'BAR_TITLE', 'BAR_FIXTURE'],
} }
const approvedHeaderSourceCommit = Object.fromEntries(Object.entries(approvedHeaderSourceChanges).flatMap(([commit, names]) => names.map(name => [name, commit]))) const approvedHeaderSourceCommit = Object.fromEntries(Object.entries(approvedHeaderSourceChanges).flatMap(([commit, names]) => names.map(name => [name, commit])))
// Declarations among `names` that differ between `base` and `current` without an approved change: none listed, or the // Declarations among `names` that differ between `base` and `current` without an approved change: none listed, or the
@@ -140,14 +140,18 @@ try {
const pickBox = await pick.boundingBox() const pickBox = await pick.boundingBox()
await pick.click() await pick.click()
check('Overview: btn_colorize 24 × 24 calls onColorPick and closes (former Colorize page)', pickBox.width === 24 && pickBox.height === 24 && (await callback()).includes('onColorPick') && await header.locator('.hm-sw-header__color-menu').count() === 0, pickBox) check('Overview: btn_colorize 24 × 24 calls onColorPick and closes (former Colorize page)', pickBox.width === 24 && pickBox.height === 24 && (await callback()).includes('onColorPick') && await header.locator('.hm-sw-header__color-menu').count() === 0, pickBox)
const bar = page.locator('.hm-sw-property-bar [data-property-id="radius"] input') // 2026-10-21 (user decision): the Bar values are read-only red text (<output>), never focused; the panel edits them.
await bar.fill('6.25'); await bar.press('Enter') const bar = page.locator('.hm-sw-property-bar [data-property-id="radius"] output')
check('Overview: property Bar Enter commits through callback', await bar.inputValue() === '6.25' && (await callback()).includes('onPropertyChange(radius, 6.25)')) check('Overview: property Bar value is read-only text (no input, no focus stop)', await bar.count() === 1 && await page.locator('.hm-sw-property-bar input').count() === 0 && await bar.evaluate(e => e.tabIndex < 0))
await bar.fill('-1'); await bar.press('Enter')
check('Overview: property Bar validation keeps min=0', await bar.inputValue() === '6.25')
const trigger = header.locator('[data-property-panel-trigger]') const trigger = header.locator('[data-property-panel-trigger]')
await trigger.click(); await page.locator('.hm-sw-property').waitFor() await trigger.click(); await page.locator('.hm-sw-property').waitFor()
check('Overview: panel opens with the shared Bar value', await page.locator('.hm-sw-property [data-property-id="radius"] input').first().inputValue() === '6.25') const panelRadius = page.locator('.hm-sw-property [data-property-id="radius"] input').first()
check('Overview: panel opens with the shared Bar value', await panelRadius.inputValue() === await bar.textContent())
await panelRadius.fill('6.25'); await panelRadius.press('Enter')
await page.waitForFunction(() => document.querySelector('.hm-sw-property-bar [data-property-id="radius"] output')?.textContent === '6.25')
check('Overview: panel edit commits through callback and updates the Bar', await bar.textContent() === '6.25' && (await callback()).includes('onPropertyChange(radius, 6.25)'))
await panelRadius.fill('-1'); await panelRadius.press('Enter')
check('Overview: panel validation keeps min=0', await panelRadius.inputValue() === '6.25' && await bar.textContent() === '6.25')
await page.keyboard.press('Escape'); await page.keyboard.press('Escape') await page.keyboard.press('Escape'); await page.keyboard.press('Escape')
check('Overview: Escape closes panel and returns focus to its trigger', await page.locator('.hm-sw-property').count() === 0 && await trigger.evaluate(e => e === document.activeElement)) check('Overview: Escape closes panel and returns focus to its trigger', await page.locator('.hm-sw-property').count() === 0 && await trigger.evaluate(e => e === document.activeElement))
} }
+8 -4
View File
@@ -188,17 +188,21 @@ const recentTile = page.locator('.hm-sw-home-menu > .hm-sw-home-menu__sections .
if (await recentTile.getAttribute('data-active') !== 'true') { await recentTile.click(); await page.waitForTimeout(300) } if (await recentTile.getAttribute('data-active') !== 'true') { await recentTile.click(); await page.waitForTimeout(300) }
const rNames = await styles('.hm-sw-recent-files__name'), rPaths = await styles('.hm-sw-recent-files__path') const rNames = await styles('.hm-sw-recent-files__name'), rPaths = await styles('.hm-sw-recent-files__path')
report.recent = { names: rNames.slice(0, 3), paths: rPaths.slice(0, 3) } report.recent = { names: rNames.slice(0, 3), paths: rPaths.slice(0, 3) }
check(`Recent files: names (${rNames.length}) 14px 700 + 1.3px outline`, rNames.length >= 10 && rNames.every(n => n.weight === '700' && n.size === '14px' && n.outline === OUTLINE_14), rNames.slice(0, 2)) check(`Recent files: names (${rNames.length}) 14px 700 + 1.3px outline`, rNames.length >= 3 && rNames.every(n => n.weight === '700' && n.size === '14px' && n.outline === OUTLINE_14), rNames.slice(0, 2))
check(`Recent files: paths (${rPaths.length}) 12px 700 + 1px outline, ellipsis kept`, rPaths.length >= 10 && rPaths.every(p => p.weight === '700' && p.size === '12px' && p.outline === OUTLINE_12), rPaths.slice(0, 2)) check(`Recent files: paths (${rPaths.length}) 12px 700 + 1px outline, ellipsis kept`, rPaths.length >= 3 && rPaths.every(p => p.weight === '700' && p.size === '12px' && p.outline === OUTLINE_12), rPaths.slice(0, 2))
// 2026-10-21 (user decision): each recent-file list shows only the 3 most recent files (15 in the Story data).
const perList = await page.locator('.hm-sw-recent-files__list').evaluateAll(lists => lists.map(l => l.querySelectorAll('.hm-sw-recent-files__row').length))
check('Recent files: each list shows 3 rows', perList.length > 0 && perList.every(n => n === 3), perList)
const row = page.locator('.hm-sw-recent-files__row').first() const row = page.locator('.hm-sw-recent-files__row').first()
const rowGeo = () => row.evaluate(r => [r.getBoundingClientRect().height, ...[...r.querySelectorAll('.hm-sw-recent-files__name, .hm-sw-recent-files__path')].map(e => Math.round(e.getBoundingClientRect().width))].join(',')) const rowGeo = () => row.evaluate(r => [r.getBoundingClientRect().height, ...[...r.querySelectorAll('.hm-sw-recent-files__name, .hm-sw-recent-files__path')].map(e => Math.round(e.getBoundingClientRect().width))].join(','))
const rowBefore = await rowGeo() const rowBefore = await rowGeo()
await stableOnHover('Recent file name', row, row.locator('.hm-sw-recent-files__name')) await stableOnHover('Recent file name', row, row.locator('.hm-sw-recent-files__name'))
await row.hover(); await page.waitForTimeout(250) await row.hover(); await page.waitForTimeout(250)
const preview = await page.locator('.hm-sw-recent-files__preview').evaluate(p => { const t = p.querySelector('.hm-sw-recent-files__preview-title'), c = getComputedStyle(t), b = p.getBoundingClientRect(), body = p.querySelector('.hm-sw-recent-files__preview-body').getBoundingClientRect() const preview = await page.locator('.hm-sw-recent-files__preview').evaluate(p => { const t = p.querySelector('.hm-sw-recent-files__preview-title'), c = getComputedStyle(t), b = p.getBoundingClientRect(), body = p.querySelector('.hm-sw-recent-files__preview-body').getBoundingClientRect()
return { title: t.textContent, weight: c.fontWeight, size: c.fontSize, color: c.color, outline: c.textShadow.split(', rgb')[0], titleW: t.getBoundingClientRect().width, overflow: c.textOverflow, w: b.width, h: b.height, bodyH: body.height } }) return { title: t.textContent, weight: c.fontWeight, size: c.fontSize, color: c.color, outline: c.textShadow.split(', rgb')[0], titleW: t.getBoundingClientRect().width, overflow: c.textOverflow, w: b.width, h: b.height, bodyH: body.height, bodyEmpty: p.querySelector('.hm-sw-recent-files__preview-body').childElementCount === 0 && !p.querySelector('.hm-sw-recent-files__preview-body').textContent } })
report.preview = preview report.preview = preview
check('Recent preview: title 14px 700 + 1.3px outline, width 294 with ellipsis, body 243px', preview.weight === '700' && preview.size === '14px' && preview.outline === OUTLINE_14 && preview.titleW === 294 && preview.overflow === 'ellipsis' && preview.bodyH === 243, preview) // 2026-10-21 (user decision): the preview frame stays on hover; its 243px body is empty (no image / thumbnail).
check('Recent preview: title 14px 700 + 1.3px outline, width 294 with ellipsis, empty body 243px', preview.bodyEmpty && preview.weight === '700' && preview.size === '14px' && preview.outline === OUTLINE_14 && preview.titleW === 294 && preview.overflow === 'ellipsis' && preview.bodyH === 243, preview)
await page.screenshot({ path: path.join(out, 'recent-files.png'), clip: { x: 0, y: 0, width: 1100, height: 900 } }) await page.screenshot({ path: path.join(out, 'recent-files.png'), clip: { x: 0, y: 0, width: 1100, height: 900 } })
check('Recent files: rows keep 50px and their text widths', rowBefore === await rowGeo() && rowBefore.startsWith('50,'), rowBefore) check('Recent files: rows keep 50px and their text widths', rowBefore === await rowGeo() && rowBefore.startsWith('50,'), rowBefore)
await row.press('Enter').catch(() => {}) await row.press('Enter').catch(() => {})
@@ -94,6 +94,9 @@ try {
console.log('firefox', (await ev('navigator.userAgent'))) console.log('firefox', (await ev('navigator.userAgent')))
// ---------- SWHeader `...` menu reference ---------- // ---------- SWHeader `...` menu reference ----------
await goto('sw-viewer--compare-with-sw-header') await goto('sw-viewer--compare-with-sw-header')
// 2026-10-21: SWHeader shows More only below 760px: the first header's 1440px Story wrapper is narrowed to 700px for
// this reference measurement (face colors and metrics do not depend on the width).
await ev(`(() => { document.querySelector('.hm-sw-header').parentElement.style.width = '700px'; return true })()`); await sleep(300)
await click('.hm-sw-header .hm-sw-header__more-trigger') await click('.hm-sw-header .hm-sw-header__more-trigger')
const reference = await faceOf('.hm-sw-header .hm-sw-header__more-menu', '.hm-sw-header__more-item') const reference = await faceOf('.hm-sw-header .hm-sw-header__more-menu', '.hm-sw-header__more-item')
const BOX = `element => { const s = getComputedStyle(element); return [s.borderTopWidth, s.borderTopStyle, s.borderTopColor, s.borderTopLeftRadius, s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor, s.boxShadow].join(' | ') }` const BOX = `element => { const s = getComputedStyle(element); return [s.borderTopWidth, s.borderTopStyle, s.borderTopColor, s.borderTopLeftRadius, s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor, s.boxShadow].join(' | ') }`
@@ -172,11 +175,15 @@ try {
fit: [...object.querySelectorAll('button')].some(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === '전체 보기'), more: !!h.querySelector('.hm-sw-viewer-header__more'), fit: [...object.querySelectorAll('button')].some(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === '전체 보기'), more: !!h.querySelector('.hm-sw-viewer-header__more'),
open: [!!open.querySelector('.hm-sw-viewer-header__open-label'), open.title, open.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect().width], open: [!!open.querySelector('.hm-sw-viewer-header__open-label'), open.title, open.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect().width],
order: [rect('.hm-sw-viewer-header__brand').right, open.getBoundingClientRect().left, open.getBoundingClientRect().right, rect('.hm-sw-viewer-header__document').left, rect('.hm-sw-viewer-header__document').right, rect('.hm-sw-viewer-header__object').left] } }))`)) order: [rect('.hm-sw-viewer-header__brand').right, open.getBoundingClientRect().left, open.getBoundingClientRect().right, rect('.hm-sw-viewer-header__document').left, rect('.hm-sw-viewer-header__document').right, rect('.hm-sw-viewer-header__object').left] } }))`))
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.length === 4 && report.every(r => r.overflow <= 0 && r.objectOverflow <= 1), report.map(r => [r.width, r.overflow, r.objectOverflow])) check('no horizontal overflow at 1920 / 1440 / 1280 / 950 / 860 / 760 / 520', report.length === 7 && report.every(r => r.overflow <= 0 && r.objectOverflow <= 1), report.map(r => [r.width, r.overflow, r.objectOverflow]))
check('layer / settings / properties always in the header with tooltips', report.every(r => r.toggles.map(t => t[0]).join() === '레이어,설정,속성' && r.toggles.every(t => t[2])), report.map(r => [r.width, r.toggles])) check('layer / settings / properties always in the header with tooltips', report.every(r => r.toggles.map(t => t[0]).join() === '레이어,설정,속성' && r.toggles.every(t => t[2])), report.map(r => [r.width, r.toggles]))
// 2026-10-21 measured steps: with the file open button this Story fits fully at 1440, as icons at 1100, More at 860 / 520. // 2026-10-21 measured steps (widths of SW Footer / SW Header Responsive More + 520): full at 1920 / 1440, icons at 950,
check('1440 full (파일 열기 + 전체 보기 labels, no More); 1100 icons only, no More; 860 / 520 icons + More', report[0].fit && !report[0].more && report[0].open[0] && report[0].toggles.every(t => t[1]) // More at 860 / 760 / 520. 1280 is at the measured full / icons edge (Firefox fits the labels, Chromium does not): either
&& !report[1].more && report[1].toggles.every(t => !t[1]) && report.slice(1).every(r => !r.open[0] && r.open[1] === '파일 열기' && Math.round(r.open[2]) === 34) && report[2].more && report[3].more, // whole state, never More.
const full = r => r.fit && !r.more && r.open[0] && r.toggles.every(t => t[1])
const icons = r => !r.more && r.toggles.every(t => !t[1]) && !r.open[0] && r.open[1] === '파일 열기' && Math.round(r.open[2]) === 34
check('1920 / 1440 full (파일 열기 + 전체 보기 labels, no More); 1280 full or icons, no More; 950 icons only, no More; 860 / 760 / 520 icons + More', report.slice(0, 2).every(full)
&& (full(report[2]) || icons(report[2])) && icons(report[3]) && report.slice(4).every(r => r.more && r.toggles.every(t => !t[1]) && !r.open[0] && r.open[1] === '파일 열기' && Math.round(r.open[2]) === 34),
report.map(r => [r.width, r.fit, r.more, r.open, r.toggles.map(t => t[1])])) report.map(r => [r.width, r.fit, r.more, r.open, r.toggles.map(t => t[1])]))
check('logo, file open, file name and toolbar never overlap', report.every(r => r.order[0] <= r.order[1] + 2.5 && r.order[2] <= r.order[3] + 0.5 && r.order[4] <= r.order[5] + 0.5), report.map(r => [r.width, r.order])) check('logo, file open, file name and toolbar never overlap', report.every(r => r.order[0] <= r.order[1] + 2.5 && r.order[2] <= r.order[3] + 0.5 && r.order[4] <= r.order[5] + 0.5), report.map(r => [r.width, r.order]))
await click('[data-width="520"] .hm-sw-viewer-header__document .hm-sw-header__document-button') await click('[data-width="520"] .hm-sw-viewer-header__document .hm-sw-header__document-button')
@@ -80,6 +80,11 @@ let reference, viewerMenu, moreMenu, refTrigger
const page = await newPage() const page = await newPage()
await open(page, 'sw-viewer--compare-with-sw-header') await open(page, 'sw-viewer--compare-with-sw-header')
const header = page.locator('.hm-sw-header').first() const header = page.locator('.hm-sw-header').first()
// 2026-10-21: SWHeader shows More only below 760px (wider headers compress instead): the reference `...` menu is
// measured with the first header's 1440px Story wrapper narrowed to 700px (measurement only; face colors and metrics
// do not depend on the width).
await header.evaluate(element => { element.parentElement.style.width = '700px' })
await page.waitForTimeout(300)
const more = header.getByRole('button', { name: '더보기' }) const more = header.getByRole('button', { name: '더보기' })
await more.click() await more.click()
const menu = header.getByRole('menu', { name: '추가 도구' }) const menu = header.getByRole('menu', { name: '추가 도구' })
@@ -223,7 +228,8 @@ let reference, viewerMenu, moreMenu, refTrigger
await open(page, 'sw-viewer--responsive') await open(page, 'sw-viewer--responsive')
const headers = page.locator('[data-width] .hm-sw-viewer-header') const headers = page.locator('[data-width] .hm-sw-viewer-header')
const report = [] const report = []
for (const [index, width] of [1440, 1100, 860, 520].entries()) { // 2026-10-21: the Story widths follow SW Footer / SW Header Responsive More (+ 520).
for (const [index, width] of [1920, 1440, 1280, 950, 860, 760, 520].entries()) {
const header = headers.nth(index) const header = headers.nth(index)
const info = await header.evaluate(element => { const info = await header.evaluate(element => {
const rect = selector => element.querySelector(selector)?.getBoundingClientRect() const rect = selector => element.querySelector(selector)?.getBoundingClientRect()
@@ -239,24 +245,24 @@ let reference, viewerMenu, moreMenu, refTrigger
} }
}) })
report.push({ width, ...info }) report.push({ width, ...info })
const b = await box(header) // Element screenshot: scrolls the row in (7 rows since 2026-10-21 do not fit one viewport).
await page.screenshot({ path: path.join(out, `responsive-${width}.png`), clip: { x: b.x, y: b.y, width: b.width, height: b.height } }) await header.screenshot({ path: path.join(out, `responsive-${width}.png`) })
} }
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), report.map(item => [item.width, item.overflow, item.objectOverflow])) check('no horizontal overflow at 1920 / 1440 / 1280 / 950 / 860 / 760 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), report.map(item => [item.width, item.overflow, item.objectOverflow]))
check('layer / settings / properties always in the header', report.every(item => JSON.stringify(item.toggles.map(toggle => toggle.name)) === '["레이어","설정","속성"]'), report.map(item => item.toggles.map(toggle => toggle.name))) check('layer / settings / properties always in the header', report.every(item => JSON.stringify(item.toggles.map(toggle => toggle.name)) === '["레이어","설정","속성"]'), report.map(item => item.toggles.map(toggle => toggle.name)))
// 2026-10-21 measured steps: full → icons only → More, by the measured room (no fixed width). With the file open button // 2026-10-21 measured steps: full → icons only → More, by the measured room (no fixed width). With the file open button
// after the logo, this Story's content fits fully at 1440, as icons at 1100, and needs More at 860 / 520. // after the logo, this Story's content fits fully at 1920 / 1440, as icons at 1280 / 950, and needs More at 860 / 760 / 520.
check('1440: full items with labels (전체 보기 shown), no More; 1100: icons only, no More', report.slice(0, 1).every(item => item.fitShown && !item.more && item.toggles.every(toggle => toggle.label)) check('1920 / 1440: full items with labels (전체 보기 shown), no More; 1280 / 950: icons only, no More', report.slice(0, 2).every(item => item.fitShown && !item.more && item.toggles.every(toggle => toggle.label))
&& !report[1].more && report[1].fitShown && report[1].toggles.every(toggle => !toggle.label && toggle.title), report.map(item => [item.width, item.fitShown, item.more])) && report.slice(2, 4).every(item => !item.more && item.fitShown && item.toggles.every(toggle => !toggle.label && toggle.title)), report.map(item => [item.width, item.fitShown, item.more]))
check('860: icons only (tooltips kept) + More', report[2].more && report[2].toggles.every(toggle => !toggle.label && toggle.title), report[2]) check('860 / 760: icons only (tooltips kept) + More', report.slice(4, 6).every(item => item.more && item.toggles.every(toggle => !toggle.label && toggle.title)), report.slice(4, 6))
check('520: icons only, then More for what still does not fit', report[3].more && report[3].toggles.every(toggle => !toggle.label && toggle.title), report[3]) check('520: icons only, then More for what still does not fit', report[6].more && report[6].toggles.every(toggle => !toggle.label && toggle.title), report[6])
check('logo, file name and toolbar never overlap', report.every(item => item.logoRight <= item.docLeft + 2 + 0.5 && item.docRight <= item.objectLeft + 0.5), report.map(item => [item.width, item.logoRight, item.docLeft, item.docRight, item.objectLeft])) check('logo, file name and toolbar never overlap', report.every(item => item.logoRight <= item.docLeft + 2 + 0.5 && item.docRight <= item.objectLeft + 0.5), report.map(item => [item.width, item.logoRight, item.docLeft, item.docRight, item.objectLeft]))
// Every item is either in the header or in More, never both and never lost. // Every item is either in the header or in More, never both and never lost.
const more = headers.nth(3).getByRole('button', { name: '추가 도구' }) const more = headers.nth(6).getByRole('button', { name: '추가 도구' })
await more.click() await more.click()
const moreMenu = page.getByRole('menu', { name: '추가 도구' }) const moreMenu = page.getByRole('menu', { name: '추가 도구' })
const moreItems = (await moreMenu.locator('[role^=menuitem]').allInnerTexts()).map(text => text.split(' · ')[0]) const moreItems = (await moreMenu.locator('[role^=menuitem]').allInnerTexts()).map(text => text.split(' · ')[0])
const inHeader = await headers.nth(3).evaluate(element => [...element.querySelectorAll('.hm-sw-viewer-header__object :is(.hm-sw-viewer-header__button:not(.hm-sw-viewer-header__more), [role=radio], .hm-sw-viewer-header__switch-label)')] const inHeader = await headers.nth(6).evaluate(element => [...element.querySelectorAll('.hm-sw-viewer-header__object :is(.hm-sw-viewer-header__button:not(.hm-sw-viewer-header__more), [role=radio], .hm-sw-viewer-header__switch-label)')]
.map(item => item.getAttribute('aria-label') ?? item.textContent.trim().replace(/[\d,]+$/, ''))) .map(item => item.getAttribute('aria-label') ?? item.textContent.trim().replace(/[\d,]+$/, '')))
const all = ['Model', 'Layout', '전체 보기', '2.5D', '어두운 배경', '레이어', '설정', '속성'] const all = ['Model', 'Layout', '전체 보기', '2.5D', '어두운 배경', '레이어', '설정', '속성']
check('520: header + More hold every item exactly once (panel toggles never in More)', all.every(name => inHeader.includes(name) !== moreItems.includes(name)) && !moreItems.some(item => ['레이어', '설정', '속성'].includes(item)), { inHeader, moreItems }) check('520: header + More hold every item exactly once (panel toggles never in More)', all.every(name => inHeader.includes(name) !== moreItems.includes(name)) && !moreItems.some(item => ['레이어', '설정', '속성'].includes(item)), { inHeader, moreItems })
+27 -24
View File
@@ -33,14 +33,14 @@ const box = locator => locator.boundingBox()
const geometry = async (page, selectors) => page.evaluate(selectors => { const geometry = async (page, selectors) => page.evaluate(selectors => {
const header = document.querySelector(selectors.header) const header = document.querySelector(selectors.header)
const base = header.getBoundingClientRect() const base = header.getBoundingClientRect()
return Object.fromEntries(Object.entries(selectors.parts).map(([name, selector]) => [name, [...document.querySelectorAll(selector)].map(element => { return Object.fromEntries(Object.entries(selectors.parts).map(([name, selector]) => [name, [...(selectors.scoped ? header : document).querySelectorAll(selector)].map(element => {
const rect = element.getBoundingClientRect() const rect = element.getBoundingClientRect()
return { x: Math.round(rect.x - base.x), y: Math.round(rect.y - base.y), w: Math.round(rect.width), h: Math.round(rect.height), text: element.textContent.trim().slice(0, 12) } return { x: Math.round(rect.x - base.x), y: Math.round(rect.y - base.y), w: Math.round(rect.width), h: Math.round(rect.height), text: element.textContent.trim().slice(0, 12) }
})])) })]))
}, selectors) }, selectors)
const parts = { const parts = {
reference: { header: 'header', parts: { document: '.cad-toolbar-document', object: '.cad-toolbar-object-area', field: '.cad-toolbar-property-field', button: '.cad-toolbar [data-slot=button]', toggle: '.cad-toolbar [data-slot=toggle]', tab: '.cad-toolbar [data-slot=tabs-trigger]', switchField: '.cad-toolbar-switch-field' } }, reference: { header: 'header', parts: { document: '.cad-toolbar-document', object: '.cad-toolbar-object-area', field: '.cad-toolbar-property-field', button: '.cad-toolbar [data-slot=button]', toggle: '.cad-toolbar [data-slot=toggle]', tab: '.cad-toolbar [data-slot=tabs-trigger]', switchField: '.cad-toolbar-switch-field' } },
package: { header: '.hm-sw-viewer-header', parts: { document: '.hm-sw-viewer-header__document', object: '.hm-sw-viewer-header__object', field: '.hm-sw-viewer-header__group[data-surface=field]', button: '.hm-sw-viewer-header__button:not([data-kind=toggle])', toggle: '.hm-sw-viewer-header__button[data-kind=toggle]', tab: '.hm-sw-viewer-header__tab', switchField: '.hm-sw-viewer-header__switch-field' } }, package: { header: '.hm-sw-viewer-header', scoped: true, parts: { document: '.hm-sw-viewer-header__document', object: '.hm-sw-viewer-header__object', field: '.hm-sw-viewer-header__group[data-surface=field]', button: '.hm-sw-viewer-header__button:not([data-kind=toggle])', toggle: '.hm-sw-viewer-header__button[data-kind=toggle]', tab: '.hm-sw-viewer-header__tab', switchField: '.hm-sw-viewer-header__switch-field' } },
} }
const comparison = {} const comparison = {}
if (reference && reference !== '-') { if (reference && reference !== '-') {
@@ -65,16 +65,17 @@ if (reference && reference !== '-') {
{ {
const page = await newPage(1920, 1000) const page = await newPage(1920, 1000)
await open(page, 'viewer-header') await open(page, 'viewer-header')
const frame = page.locator('[data-viewer-frame]') // 2026-10-21: the Story also shows the minimize / close version below; the checks use the first (basic) frame.
const frame = page.locator('[data-viewer-frame]').first()
const origin = await box(frame) const origin = await box(frame)
comparison.package = await geometry(page, parts.package) comparison.package = await geometry(page, parts.package)
await page.screenshot({ path: path.join(out, 'package-1440-loaded.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 56 } }) await page.screenshot({ path: path.join(out, 'package-1440-loaded.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 56 } })
// 2026-10-21: the samples are the file-name list (SWHeaderDocumentTitle), see viewer-logo-document-menu.browser.mjs. // 2026-10-21: the samples are the file-name list (SWHeaderDocumentTitle), see viewer-logo-document-menu.browser.mjs.
await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').click() await frame.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').click()
await page.screenshot({ path: path.join(out, 'package-1440-sample-menu.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 200 } }) await page.screenshot({ path: path.join(out, 'package-1440-sample-menu.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 200 } })
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await page.getByRole('button', { name: '레이어' }).click() await frame.getByRole('button', { name: '레이어' }).click()
await page.getByRole('button', { name: '설정' }).click() await frame.getByRole('button', { name: '설정' }).click()
await page.waitForTimeout(200) await page.waitForTimeout(200)
await page.screenshot({ path: path.join(out, 'package-1440-panels.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 620 } }) await page.screenshot({ path: path.join(out, 'package-1440-panels.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 620 } })
await page.close() await page.close()
@@ -97,11 +98,13 @@ if (comparison.reference) {
// Wide enough for the 1440px story frame plus the story page padding (panels at the frame right edge stay on screen). // Wide enough for the 1440px story frame plus the story page padding (panels at the frame right edge stay on screen).
const page = await newPage(1920, 1000) const page = await newPage(1920, 1000)
await open(page, 'viewer-header') await open(page, 'viewer-header')
// 2026-10-21: the Story also shows the minimize / close version below; these checks use the first (basic) frame.
const root = page.locator('[data-viewer-frame]').first()
// Samples (2026-10-21): the file-name list (SWHeaderDocumentTitle) replaced the 샘플 menu button. Click opens, rows listed, // Samples (2026-10-21): the file-name list (SWHeaderDocumentTitle) replaced the 샘플 menu button. Click opens, rows listed,
// Escape closes and returns focus; the logo menu (Open) is checked in viewer-logo-document-menu.browser.mjs. // Escape closes and returns focus; the logo menu (Open) is checked in viewer-logo-document-menu.browser.mjs.
const samples = page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button') const samples = page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').first()
await samples.click() await samples.click()
const menu = page.getByRole('group', { name: '문서 목록' }) const menu = root.getByRole('group', { name: '문서 목록' })
check('document list opens on click', await menu.isVisible()) check('document list opens on click', await menu.isVisible())
check('document list has the 3 samples', (await menu.locator('.hm-sw-header__document-item').count()) === 3) check('document list has the 3 samples', (await menu.locator('.hm-sw-header__document-item').count()) === 3)
const menuBox = await box(menu), triggerBox = await box(samples) const menuBox = await box(menu), triggerBox = await box(samples)
@@ -119,34 +122,34 @@ if (comparison.reference) {
// 2026-10-21: no Open below the rows (file open is the header button); ArrowUp wraps to the last sample. // 2026-10-21: no Open below the rows (file open is the header button); ArrowUp wraps to the last sample.
await page.keyboard.press('ArrowUp') await page.keyboard.press('ArrowUp')
await page.keyboard.press('Enter') await page.keyboard.press('Enter')
check('Enter selects a sample (document changes)', (await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-name').innerText()) === 'simple.dxf') check('Enter selects a sample (document changes)', (await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-name').first().innerText()) === 'simple.dxf')
check('onDocumentSelect reported', (await log(page)).includes('samples/sample-dxf'), await log(page)) check('onDocumentSelect reported', (await log(page)).includes('samples/sample-dxf'), await log(page))
// Outside click closes. // Outside click closes.
await samples.click() await samples.click()
await page.mouse.click(700, 600) await page.mouse.click(700, 600)
check('outside click closes the list', (await page.getByRole('group', { name: '문서 목록' }).count()) === 0) check('outside click closes the list', (await root.getByRole('group', { name: '문서 목록' }).count()) === 0)
// Tabs: arrow keys select. // Tabs: arrow keys select.
const model = page.getByRole('radio', { name: /Model/ }) const model = root.getByRole('radio', { name: /Model/ })
await model.focus() await model.focus()
await page.keyboard.press('ArrowRight') await page.keyboard.press('ArrowRight')
check('tabs ArrowRight selects Layout', (await page.getByRole('radio', { name: /Layout/ }).getAttribute('aria-checked')) === 'true') check('tabs ArrowRight selects Layout', (await root.getByRole('radio', { name: /Layout/ }).getAttribute('aria-checked')) === 'true')
// Switch // Switch
const depth = page.getByRole('switch', { name: '2.5D' }) const depth = root.getByRole('switch', { name: '2.5D' })
await depth.click() await depth.click()
check('header switch toggles', (await depth.getAttribute('aria-checked')) === 'true' && (await log(page)).includes('2.5d=true')) check('header switch toggles', (await depth.getAttribute('aria-checked')) === 'true' && (await log(page)).includes('2.5d=true'))
// Hover face (2026-10-21: 전체 보기 left the header row for More, so the panel toggle is hovered). // Hover face (2026-10-21: 전체 보기 left the header row for More, so the panel toggle is hovered).
const hovered = page.getByRole('button', { name: '레이어', exact: true }) const hovered = root.getByRole('button', { name: '레이어', exact: true })
await hovered.hover() await hovered.hover()
check('hover face #266651', (await hovered.evaluate(element => getComputedStyle(element).backgroundColor)) === 'rgb(38, 102, 81)') check('hover face #266651', (await hovered.evaluate(element => getComputedStyle(element).backgroundColor)) === 'rgb(38, 102, 81)')
// Toggle opens the panel; Escape inside closes it and focus goes back. // Toggle opens the panel; Escape inside closes it and focus goes back.
const layers = page.getByRole('button', { name: '레이어', exact: true }) const layers = root.getByRole('button', { name: '레이어', exact: true })
await layers.click() await layers.click()
const layerPanel = page.getByRole('dialog', { name: /레이어 \(/ }) const layerPanel = root.getByRole('dialog', { name: /레이어 \(/ })
check('toggle opens panel', await layerPanel.isVisible()) check('toggle opens panel', await layerPanel.isVisible())
check('toggle pressed state + #FFC600', (await layers.getAttribute('aria-pressed')) === 'true' && (await layers.evaluate(element => getComputedStyle(element).color)) === 'rgb(255, 198, 0)') check('toggle pressed state + #FFC600', (await layers.getAttribute('aria-pressed')) === 'true' && (await layers.evaluate(element => getComputedStyle(element).color)) === 'rgb(255, 198, 0)')
check('panel focused on open', await layerPanel.evaluate(element => element.contains(document.activeElement))) check('panel focused on open', await layerPanel.evaluate(element => element.contains(document.activeElement)))
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
check('Escape closes panel', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0) check('Escape closes panel', (await root.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
check('focus returns to toggle', await layers.evaluate(element => element === document.activeElement)) check('focus returns to toggle', await layers.evaluate(element => element === document.activeElement))
// Resize by the native corner (right after opening, top-end placement). // Resize by the native corner (right after opening, top-end placement).
await layers.click() await layers.click()
@@ -171,7 +174,7 @@ if (comparison.reference) {
await page.mouse.down() await page.mouse.down()
await page.mouse.move(after.x + 3000, after.y + 3000, { steps: 6 }) await page.mouse.move(after.x + 3000, after.y + 3000, { steps: 6 })
await page.mouse.up() await page.mouse.up()
const frame = await box(page.locator('.sw-viewer-story-canvas')) const frame = await box(root.locator('.sw-viewer-story-canvas'))
const clamped = await box(layerPanel) const clamped = await box(layerPanel)
check('drag stays inside bounds (10px edge)', Math.round(frame.x + frame.width - (clamped.x + clamped.width)) === 10 && Math.round(frame.y + frame.height - (clamped.y + clamped.height)) === 10, check('drag stays inside bounds (10px edge)', Math.round(frame.x + frame.width - (clamped.x + clamped.width)) === 10 && Math.round(frame.y + frame.height - (clamped.y + clamped.height)) === 10,
`right gap ${Math.round(frame.x + frame.width - (clamped.x + clamped.width))}, bottom gap ${Math.round(frame.y + frame.height - (clamped.y + clamped.height))}`) `right gap ${Math.round(frame.x + frame.width - (clamped.x + clamped.width))}, bottom gap ${Math.round(frame.y + frame.height - (clamped.y + clamped.height))}`)
@@ -201,18 +204,18 @@ if (comparison.reference) {
await close.hover() await close.hover()
check('close hover = SWPopup hover asset', await close.evaluate(element => getComputedStyle(element.querySelector('.hm-sw-property__close-hover')).visibility === 'visible' && getComputedStyle(element.querySelector('.hm-sw-property__close-default')).visibility === 'hidden')) check('close hover = SWPopup hover asset', await close.evaluate(element => getComputedStyle(element.querySelector('.hm-sw-property__close-hover')).visibility === 'visible' && getComputedStyle(element.querySelector('.hm-sw-property__close-default')).visibility === 'hidden'))
await close.click() await close.click()
check('close button closes', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0) check('close button closes', (await root.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
// Two panels: the last touched comes to the front. // Two panels: the last touched comes to the front.
await layers.click() await layers.click()
await page.getByRole('button', { name: '설정', exact: true }).click() await root.getByRole('button', { name: '설정', exact: true }).click()
const options = page.getByRole('dialog', { name: '옵션' }) const options = root.getByRole('dialog', { name: '옵션' })
const z = async () => Promise.all([layerPanel, options].map(item => item.evaluate(element => Number(getComputedStyle(element).zIndex)))) const z = async () => Promise.all([layerPanel, options].map(item => item.evaluate(element => Number(getComputedStyle(element).zIndex))))
await layerPanel.locator('.hm-sw-property__titlebar').click({ position: { x: 30, y: 15 } }) await layerPanel.locator('.hm-sw-property__titlebar').click({ position: { x: 30, y: 15 } })
const [zl, zo] = await z() const [zl, zo] = await z()
check('last touched panel on top', zl > zo, `layer z ${zl}, options z ${zo}`) check('last touched panel on top', zl > zo, `layer z ${zl}, options z ${zo}`)
// Modal popup: backdrop, focus trap, Escape. // Modal popup: backdrop, focus trap, Escape.
await page.getByRole('button', { name: '정보 및 라이선스' }).click() await root.getByRole('button', { name: '정보 및 라이선스' }).click()
const about = page.getByRole('dialog', { name: /라이선스/ }) const about = root.getByRole('dialog', { name: /라이선스/ })
const confirm = about.getByRole('button', { name: '확인' }) const confirm = about.getByRole('button', { name: '확인' })
await confirm.hover() await confirm.hover()
const confirmHover = await face(confirm) const confirmHover = await face(confirm)
@@ -222,7 +225,7 @@ if (comparison.reference) {
for (let index = 0; index < 4; index++) await page.keyboard.press('Tab') for (let index = 0; index < 4; index++) await page.keyboard.press('Tab')
check('modal focus stays inside', await about.evaluate(element => element.contains(document.activeElement))) check('modal focus stays inside', await about.evaluate(element => element.contains(document.activeElement)))
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
check('modal Escape closes', (await page.getByRole('dialog', { name: /라이선스/ }).count()) === 0) check('modal Escape closes', (await root.getByRole('dialog', { name: /라이선스/ }).count()) === 0)
await page.close() await page.close()
} }
{ {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

+1 -3
View File
@@ -9,7 +9,7 @@ import "@hm-design/sw/styles.css"
import { Crosshair, Grid3x3, Magnet, Move, RotateCw, Scissors, Spline, Square } from "lucide-react" import { Crosshair, Grid3x3, Magnet, Move, RotateCw, Scissors, Spline, Square } from "lucide-react"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { import {
AUX_ITEMS, CATEGORIES, DRAW_TOOLS, GuideFrame, GuideMovie, HISTORY, LAYOUTS, Log, Pin, REFERENCE_AUX, REFERENCE_CATEGORIES, REFERENCE_HISTORY, AUX_ITEMS, CATEGORIES, DRAW_TOOLS, GuideFrame, HISTORY, LAYOUTS, Log, Pin, REFERENCE_AUX, REFERENCE_CATEGORIES, REFERENCE_HISTORY,
useFooterDemo, useFooterDemo,
} from "./sw-footer-fixture" } from "./sw-footer-fixture"
import "./sw-stories.css" import "./sw-stories.css"
@@ -66,7 +66,6 @@ function Workspace({ width = "100%" }: { width?: number | string }) {
status="7.4177, -0.2807, 0.0000" status="7.4177, -0.2807, 0.0000"
command={<SWFooterCommandLine history={demo.history} onSubmit={demo.onCommand} expanded={commandOpen} onExpandedChange={setCommandOpen} />} command={<SWFooterCommandLine history={demo.history} onSubmit={demo.onCommand} expanded={commandOpen} onExpandedChange={setCommandOpen} />}
activeToolId={demo.activeToolId} onToolSelect={demo.onToolSelect} onSlotAdd={demo.onSlotAdd} onCategoryAdd={demo.onCategoryAdd} activeToolId={demo.activeToolId} onToolSelect={demo.onToolSelect} onSlotAdd={demo.onSlotAdd} onCategoryAdd={demo.onCategoryAdd}
slotGuide={{ media: <GuideMovie /> }}
auxItems={demo.aux} onAuxPressedChange={demo.onAuxPressedChange} auxItems={demo.aux} onAuxPressedChange={demo.onAuxPressedChange}
auxAll={{ onClick: demo.onSide("auxAll") }} commandAll={{ onClick: () => { setCommandOpen(open => !open); demo.onSide("commandAll")() }, pressed: commandOpen }} auxAll={{ onClick: demo.onSide("auxAll") }} commandAll={{ onClick: () => { setCommandOpen(open => !open); demo.onSide("commandAll")() }, pressed: commandOpen }}
layoutTabs={<SWFooterLayoutTabs layouts={demo.layouts} value={demo.space} onValueChange={demo.onSpaceChange} layoutTabs={<SWFooterLayoutTabs layouts={demo.layouts} value={demo.space} onValueChange={demo.onSpaceChange}
@@ -338,7 +337,6 @@ function CustomFooter() {
setCategories(list => list.map(item => (item.id === category ? { ...item, tools: [...item.tools, { id: `pt${index}`, label: `Pt ${index + 1}`, ...swFooterToolIcons.point }] } : item))) setCategories(list => list.map(item => (item.id === category ? { ...item, tools: [...item.tools, { id: `pt${index}`, label: `Pt ${index + 1}`, ...swFooterToolIcons.point }] } : item)))
note(`slotAdd:${category}#${index}`) note(`slotAdd:${category}#${index}`)
}} }}
slotGuide={{ title: "명령 추가", caption: "클릭", media: <span className="sw-footer-story-guide-text">빈 칸을 눌러 Point 명령 추가</span> }}
onCategoryAdd={() => { setCategories(list => [...list, { id: `c${list.length}`, label: `분류 ${list.length + 1}`, tools: [] }]); note("categoryAdd") }} onCategoryAdd={() => { setCategories(list => [...list, { id: `c${list.length}`, label: `분류 ${list.length + 1}`, tools: [] }]); note("categoryAdd") }}
auxItems={aux} onAuxPressedChange={(id, pressed) => setAux(items => items.map(item => (item.id === id ? { ...item, pressed } : item)))} auxItems={aux} onAuxPressedChange={(id, pressed) => setAux(items => items.map(item => (item.id === id ? { ...item, pressed } : item)))}
onAuxItemClick={id => note(`aux:${id}`)} onAuxItemClick={id => note(`aux:${id}`)}
+2 -6
View File
@@ -10,7 +10,7 @@ import "@hm-design/sw/styles.css"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import "./sw-home-menu.css" import "./sw-home-menu.css"
import { import {
figmaHomeMenuItemIds, figmaHomeMenuSections, figmaRecentFiles, withRecentFiles, figmaHomeMenuSections, figmaRecentFiles, withRecentFiles,
} from "./sw-home-menu-fixture" } from "./sw-home-menu-fixture"
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg" import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
import figmaPropertyBar from "./assets/home-menu/figma-26-10492.png" import figmaPropertyBar from "./assets/home-menu/figma-26-10492.png"
@@ -98,11 +98,7 @@ export const HomeMenu: Story = {
<StorySection title="열림 (현재 폭)" description="열림 동안 로고는 Home On(노란 테두리), Header 상·하단에 #5F4B0F 2px 선이 표시되고 닫으면 기본으로 돌아갑니다(Figma 26:10591 / 662:67267)."> <StorySection title="열림 (현재 폭)" description="열림 동안 로고는 Home On(노란 테두리), Header 상·하단에 #5F4B0F 2px 선이 표시되고 닫으면 기본으로 돌아갑니다(Figma 26:10591 / 662:67267).">
<HeaderWithMenu defaultOpen autoFocus={false} /> <HeaderWithMenu defaultOpen autoFocus={false} />
</StorySection> </StorySection>
<StorySection title="Default / Variant2 · Hover / Variant3 · Active" description="Figma 버튼 세트 15개(26:11355 … 26:11631)는 모두 같은 SWHomeMenuItem입니다. Variant2(Hover)는 마우스를 올려 확인하며 Figma에서 Variant3와 시각 속성이 같습니다. Variant3는 activeIds로 지정합니다."> <StorySection title="최근파일 하위 패널 (662:67267)" description="최근파일 타일을 누르면(Variant3) 메뉴 하단 강조가 전체 2px 외곽선으로 바뀌고 그 아래에 최근파일 목록이 열립니다. 행에 마우스를 올리면 2뎁스 미리보기 영역이 옆에 나타나며 이미지는 표시하지 않고 빈 영역으로 둡니다(2026-10-21). 다시 누르거나 Escape로 닫습니다. 최근파일이 더보기 안에 있으면 목록은 더보기 타일 아래에 열리고 더보기 타일이 Variant3가 됩니다. 목록은 Story 데이터입니다.">
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen autoFocus={false} sections={figmaHomeMenuSections} closeOnBackgroundClick={false} aria-label="Default" /></SWTheme>
<SWTheme className="sw-home-menu-story__stage"><SWHomeMenu defaultOpen autoFocus={false} sections={figmaHomeMenuSections} activeIds={figmaHomeMenuItemIds} closeOnBackgroundClick={false} aria-label="Active" /></SWTheme>
</StorySection>
<StorySection title="최근파일 하위 패널 (662:67267)" description="최근파일 타일을 누르면(Variant3) 메뉴 하단 강조가 전체 2px 외곽선으로 바뀌고 그 아래에 최근파일 목록이 열립니다. 행에 마우스를 올리면 2뎁스 미리보기가 옆에 나타납니다. 다시 누르거나 Escape로 닫습니다. 최근파일이 더보기 안에 있으면 목록은 더보기 타일 아래에 열리고 더보기 타일이 Variant3가 됩니다. 목록과 미리보기 이미지는 Story 데이터입니다.">
<div className="sw-home-menu-story__tall"><HeaderWithMenu defaultOpen autoFocus={false} defaultActiveIds={["recent"]}>{log => log}</HeaderWithMenu></div> <div className="sw-home-menu-story__tall"><HeaderWithMenu defaultOpen autoFocus={false} defaultActiveIds={["recent"]}>{log => log}</HeaderWithMenu></div>
</StorySection> </StorySection>
<StorySection title="통합 · 속성창 · SWPopup · Escape" description="Escape는 Focus가 있는 곳을 먼저 닫습니다(메뉴 안: 하위 패널 → 메뉴, Popup 안: 그 Popup). Focus가 모두의 밖이면 가장 최근에 열거나 사용한 하나만 닫습니다."> <StorySection title="통합 · 속성창 · SWPopup · Escape" description="Escape는 Focus가 있는 곳을 먼저 닫습니다(메뉴 안: 하위 패널 → 메뉴, Popup 안: 그 Popup). Focus가 모두의 밖이면 가장 최근에 열거나 사용한 하나만 닫습니다.">
+10 -7
View File
@@ -15,12 +15,14 @@ interface OverviewProps {
propertyFixture: (selection: Selection) => SWHeaderProperty[] propertyFixture: (selection: Selection) => SWHeaderProperty[]
propertySelection: (selection: Selection) => SWHeaderPropertySelection propertySelection: (selection: Selection) => SWHeaderPropertySelection
barFixture: SWHeaderProperty[] barFixture: SWHeaderProperty[]
/** Object text of the property Bar (2026-10-21: the object type, e.g. 원). Default: the selection title. */
barTitle?: string
/** Logo (home) menu of the SW normal mode: the logo opens and closes it (SWHeader homeMenu). */ /** Logo (home) menu of the SW normal mode: the logo opens and closes it (SWHeader homeMenu). */
homeMenuSections?: readonly SWHomeMenuSectionData[] homeMenuSections?: readonly SWHomeMenuSectionData[]
} }
/** Storybook host only; dropdowns, validation, editing and focus use the public SW components. */ /** Storybook host only; dropdowns, validation, editing and focus use the public SW components. */
export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFixture, propertySelection, barFixture, homeMenuSections }: OverviewProps) { export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFixture, propertySelection, barFixture, barTitle, homeMenuSections }: OverviewProps) {
const controlId = useId() const controlId = useId()
const slotRef = useRef<HTMLDivElement>(null) const slotRef = useRef<HTMLDivElement>(null)
const headerRef = useRef<HTMLElement>(null) const headerRef = useRef<HTMLElement>(null)
@@ -117,11 +119,12 @@ export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFi
onColorPick: () => record("onColorPick"), onColorDetail: () => record("onColorDetail"), onColorPick: () => record("onColorPick"), onColorDetail: () => record("onColorDetail"),
} }
const guardedProperties = properties.map(property => ({ ...property, disabled: disabled || property.disabled, readOnly: readOnly || property.readOnly })) const guardedProperties = properties.map(property => ({ ...property, disabled: disabled || property.disabled, readOnly: readOnly || property.readOnly }))
const barProperties = guardedProperties.flatMap(property => { // Bar order = barFixture order (2026-10-21: 반지름 / 지름 / 원주 / 면적), then the preview fields.
const bar = barFixture.find(item => item.id === property.id) const barProperties = [
return bar ? [{ ...bar, value: property.value, disabled: property.disabled, readOnly: property.readOnly }] ...barFixture.flatMap(bar => guardedProperties.filter(property => property.id === bar.id)
: property.id.startsWith("preview-extra-") ? [property] : [] .map(property => ({ ...bar, value: property.value, disabled: property.disabled, readOnly: property.readOnly }))),
}) ...guardedProperties.filter(property => property.id.startsWith("preview-extra-")),
]
const layer = layers.find(item => item.id === value("layer")) const layer = layers.find(item => item.id === value("layer"))
const line = fixtures.lineTypes.find(item => item.id === value("line")) const line = fixtures.lineTypes.find(item => item.id === value("line"))
const color = parseSWHeaderColor(value("color")) const color = parseSWHeaderColor(value("color"))
@@ -184,7 +187,7 @@ export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFi
onDimensionStyleOpen={() => record("onDimensionStyleOpen")} onDimensionStyleDetail={() => record("onDimensionStyleDetail")} onDimensionStyleOpen={() => record("onDimensionStyleOpen")} onDimensionStyleDetail={() => record("onDimensionStyleDetail")}
propertyBarOpen={barOpen} onPropertyBarOpenChange={next => { setBarOpen(next); record(`propertyBarOpen(${next})`) }} propertyBarOpen={barOpen} onPropertyBarOpenChange={next => { setBarOpen(next); record(`propertyBarOpen(${next})`) }}
propertyPanelOpen={panelOpen} onPropertyPanelOpenChange={next => { setPanelOpen(next); record(`propertyPanelOpen(${next})`) }} propertyPanelOpen={panelOpen} onPropertyPanelOpenChange={next => { setPanelOpen(next); record(`propertyPanelOpen(${next})`) }}
propertyBar={{ selection: propertySelection(example), properties: barProperties, onPropertyChange, readOnly, disabled }} propertyBar={{ selection: { ...propertySelection(example), title: (propertySelection(example).kind === "single" && barTitle) || propertySelection(example).title }, properties: barProperties, onPropertyChange, readOnly, disabled }}
propertyPanel={{ selection: propertySelection(example), properties: guardedProperties, groups: propertyGroups, propertyPanel={{ selection: propertySelection(example), properties: guardedProperties, groups: propertyGroups,
expandedGroupIds: expanded, onExpandedGroupsChange: setExpanded, onPropertyChange, menus, expandedGroupIds: expanded, onExpandedGroupsChange: setExpanded, onPropertyChange, menus,
shapeContent: emptyShape ? null : undefined, onOverviewClick: () => changeExample("all"), shapeContent: emptyShape ? null : undefined, onOverviewClick: () => changeExample("all"),
+16 -11
View File
@@ -190,7 +190,7 @@ export const Overview: Story = {
render: () => <SWHeaderOverview initial={{ ...figmaArgs, ...callbacks }} render: () => <SWHeaderOverview initial={{ ...figmaArgs, ...callbacks }}
fixtures={{ documents: SAMPLE_DOCUMENTS, layers: PROPERTY_LAYERS, colors: FIGMA_COLORS, userColors: FIGMA_USER_COLORS, fixtures={{ documents: SAMPLE_DOCUMENTS, layers: PROPERTY_LAYERS, colors: FIGMA_COLORS, userColors: FIGMA_USER_COLORS,
lineTypes: PROPERTY_LINE_TYPES, lineWeights: FIGMA_LINE_WEIGHTS, textStyles: FIGMA_TEXT_STYLES, dimensionStyles: FIGMA_DIMENSION_STYLES }} lineTypes: PROPERTY_LINE_TYPES, lineWeights: FIGMA_LINE_WEIGHTS, textStyles: FIGMA_TEXT_STYLES, dimensionStyles: FIGMA_DIMENSION_STYLES }}
propertyGroups={PROPERTY_GROUPS} propertyFixture={propertyFixture} propertySelection={propertySelection} barFixture={BAR_FIXTURE} propertyGroups={PROPERTY_GROUPS} propertyFixture={propertyFixture} propertySelection={propertySelection} barFixture={BAR_FIXTURE} barTitle={BAR_TITLE}
homeMenuSections={OVERVIEW_HOME_MENU} />, homeMenuSections={OVERVIEW_HOME_MENU} />,
} }
@@ -422,7 +422,7 @@ export function ComposedDemo() {
onPanelToggle={() => { setActiveMenu(null); setPanel(next => !next); record(`onPropertyPanelOpenChange(${!panel})`) }} /> onPanelToggle={() => { setActiveMenu(null); setPanel(next => !next); record(`onPropertyPanelOpenChange(${!panel})`) }} />
</div> </div>
</div> </div>
{bar && <SWHeaderPropertyBar selection={propertySelection("circle")} properties={panelProperties.filter(item => BAR_FIXTURE.some(barItem => barItem.id === item.id))} {bar && <SWHeaderPropertyBar selection={{ ...propertySelection("circle"), title: BAR_TITLE }} properties={BAR_FIXTURE.flatMap(barItem => panelProperties.filter(item => item.id === barItem.id)).map(item => ({ ...item, readOnly: true }))}
onPropertyChange={(property, next) => change(property.id, next, "onPropertyChange")} />} onPropertyChange={(property, next) => change(property.id, next, "onPropertyChange")} />}
{panel && <SWHeaderPropertyPanel id={panelId} anchorRef={detailRef} selection={propertySelection("circle")} properties={panelProperties} groups={PROPERTY_GROUPS} {panel && <SWHeaderPropertyPanel id={panelId} anchorRef={detailRef} selection={propertySelection("circle")} properties={panelProperties} groups={PROPERTY_GROUPS}
menus={{ ...menus, layers: availableLayers.map(layer => ({ ...layer, id: layer.name })) }} menus={{ ...menus, layers: availableLayers.map(layer => ({ ...layer, id: layer.name })) }}
@@ -776,7 +776,9 @@ function StyleHeader({ onLog, showState, ...props }: StyleHostProps) {
) )
} }
const MORE_WIDTHS = [1440, 768, 390] // 2026-10-21: same widths as the SW Footer Responsive More. From 760px no More; the five menus stay in the header
// (그리기모드 text hides, then the file name and values shorten).
const MORE_WIDTHS = [1920, 1440, 1280, 950, 860, 760]
export const ResponsiveMore: Story = { export const ResponsiveMore: Story = {
name: "Responsive More", name: "Responsive More",
@@ -802,17 +804,17 @@ const PROPERTY_GROUPS: SWHeaderPropertyGroup[] = [
const PROPERTY_FIXTURE: SWHeaderProperty[] = [ const PROPERTY_FIXTURE: SWHeaderProperty[] = [
{ id: "layer", name: "레이어", value: "0_Layer_Cludmark", type: "text", groupId: "common", picker: "layer", validate: draft => draft.length <= 40 }, { id: "layer", name: "레이어", value: "0_Layer_Cludmark", type: "text", groupId: "common", picker: "layer", validate: draft => draft.length <= 40 },
{ id: "color", name: "색상", value: "255,229,0", type: "color", groupId: "common" }, { id: "color", name: "색상", value: "255,229,0", type: "color", groupId: "common" },
{ id: "line", name: "선스타일", value: "Continuous", type: "select", groupId: "common", unit: "(ByL)", linePreview: true, options: [{ id: "Continuous", name: "Continuous" }, { id: "CENTER", name: "CENTER" }, { id: "HIDDEN", name: "HIDDEN" }] }, { id: "line", name: "선꼴", value: "Continuous", type: "select", groupId: "common", unit: "(ByL)", linePreview: true, options: [{ id: "Continuous", name: "Continuous" }, { id: "CENTER", name: "CENTER" }, { id: "HIDDEN", name: "HIDDEN" }] },
{ id: "weight", name: "선가중치", value: "0.25", type: "number", groupId: "common", min: 0 }, { id: "weight", name: "선가중치", value: "0.25", type: "number", groupId: "common", min: 0, annotation: "(ByD, ByL)" },
{ id: "scale", name: "선축적", value: "1.0000", type: "number", groupId: "common", min: .0001, decimals: 4, unit: "(LTscale:1)" }, { id: "scale", name: "선축적", value: "1.0000", type: "number", groupId: "common", min: .0001, decimals: 4, unit: "(LTscale:1)" },
{ id: "opacity", name: "투명도", value: "0.25", type: "slider", groupId: "common", min: 0, max: 1, step: .01, annotation: "(ByL)", unit: "%" }, { id: "opacity", name: "투명도", value: "0.25", type: "slider", groupId: "common", min: 0, max: 1, step: .01, annotation: "(ByL)", unit: "%" },
{ id: "link", name: "하이퍼링크", value: "--", type: "text", groupId: "common" }, { id: "link", name: "하이퍼링크", value: "--", type: "text", groupId: "common" },
{ id: "thickness", name: "두께", value: "0.25", type: "number", groupId: "common", unit: "mm", min: 0 }, { id: "thickness", name: "두께", value: "0.25", type: "number", groupId: "common", unit: "mm", min: 0 },
{ id: "material", name: "재료", value: "-", type: "computed", groupId: "common", unit: "(ByL)" }, { id: "material", name: "재료", value: "-", type: "computed", groupId: "common", unit: "(ByL)" },
{ id: "circumference", name: "원주", value: "0.25", type: "computed", groupId: "shape", graphicSlot: "circumference" }, { id: "circumference", name: "원주", value: "0.25", type: "number", groupId: "shape", graphicSlot: "circumference", min: 0 },
{ id: "diameter", name: "지름", value: "8.1502", type: "number", groupId: "shape", graphicSlot: "diameter", min: 0 }, { id: "diameter", name: "지름", value: "8.1502", type: "number", groupId: "shape", graphicSlot: "diameter", min: 0 },
{ id: "radius", name: "반지름", value: "4.0526", type: "number", groupId: "shape", graphicSlot: "radius", min: 0 }, { id: "radius", name: "반지름", value: "4.0526", type: "number", groupId: "shape", graphicSlot: "radius", min: 0 },
{ id: "area", name: "면적", value: "51.5962", type: "computed", groupId: "shape", graphicSlot: "area" }, { id: "area", name: "면적", value: "51.5962", type: "number", groupId: "shape", graphicSlot: "area", min: 0 },
{ id: "x", name: "X", value: "48.0808", type: "number", groupId: "position" }, { id: "x", name: "X", value: "48.0808", type: "number", groupId: "position" },
{ id: "y", name: "Y", value: "14.9160", type: "number", groupId: "position" }, { id: "y", name: "Y", value: "14.9160", type: "number", groupId: "position" },
{ id: "z", name: "Z", value: "0.0000", type: "number", groupId: "position" }, { id: "z", name: "Z", value: "0.0000", type: "number", groupId: "position" },
@@ -886,11 +888,14 @@ export const KGuide: Story = {
parameters: { controls: { disable: true } }, parameters: { controls: { disable: true } },
render: () => <StoryPage><StoryPageHeader kicker={KICKER} title="K 가이드 반복 애니메이션" description="기존 K 버튼 Hover/Focus → 원본 이미지 Pan·Zoom 반복. 영역을 벗어나면 정지, 재진입은 처음부터. Reduced Motion은 정지 이미지." /><div className="sw-story-property-room"><SWHeader {...figmaArgs} {...callbacks} /></div></StoryPage>, render: () => <StoryPage><StoryPageHeader kicker={KICKER} title="K 가이드 반복 애니메이션" description="기존 K 버튼 Hover/Focus → 원본 이미지 Pan·Zoom 반복. 영역을 벗어나면 정지, 재진입은 처음부터. Reduced Motion은 정지 이미지." /><div className="sw-story-property-room"><SWHeader {...figmaArgs} {...callbacks} /></div></StoryPage>,
} }
/* 2026-10-21: the property Bar shows the object's calculated values in this order; they are not edited there (computed,
plain text without focus). The Bar's object text is the object type only (원). */
const BAR_TITLE = "원"
const BAR_FIXTURE: SWHeaderProperty[] = [ const BAR_FIXTURE: SWHeaderProperty[] = [
{ id: "radius", name: "반지름", value: "4.0526", type: "number", groupId: "shape", min: 0 }, { id: "radius", name: "반지름", value: "4.0526", type: "computed", groupId: "shape" },
{ id: "diameter", name: "지름", value: "8.1052", type: "number", groupId: "shape", min: 0 }, { id: "diameter", name: "지름", value: "8.1052", type: "computed", groupId: "shape" },
{ id: "circumference", name: "원주", value: "8.1052", type: "number", groupId: "shape", min: 0 }, { id: "circumference", name: "원주", value: "8.1052", type: "computed", groupId: "shape" },
{ id: "area", name: "면적", value: "51.5962", type: "number", groupId: "shape", min: 0 }, { id: "area", name: "면적", value: "51.5962", type: "computed", groupId: "shape" },
] ]
/* Property panel: the four Figma states with the same component and host data. */ /* Property panel: the four Figma states with the same component and host data. */
const PROPERTY_STATES: { example: PropertyExample; title: string }[] = [ const PROPERTY_STATES: { example: PropertyExample; title: string }[] = [
+4 -7
View File
@@ -4,7 +4,6 @@ import type { ReactNode } from "react"
import type { SWFileEntry, SWHomeMenuSectionData } from "@hm-design/sw" import type { SWFileEntry, SWHomeMenuSectionData } from "@hm-design/sw"
import monitorA from "./assets/home-menu/file-open-monitor-a.svg" import monitorA from "./assets/home-menu/file-open-monitor-a.svg"
import monitorB from "./assets/home-menu/file-open-monitor-b.svg" import monitorB from "./assets/home-menu/file-open-monitor-b.svg"
import previewImage from "./assets/home-menu/figma-preview-h1100.png"
import save from "./assets/home-menu/save.svg" import save from "./assets/home-menu/save.svg"
import saveHover from "./assets/home-menu/save-hover.svg" import saveHover from "./assets/home-menu/save-hover.svg"
import saveAs from "./assets/home-menu/save-as.svg" import saveAs from "./assets/home-menu/save-as.svg"
@@ -89,9 +88,9 @@ export const figmaHomeMenuSections: SWHomeMenuSectionData[] = [
export const figmaHomeMenuItemIds = figmaHomeMenuSections.flatMap(section => section.items.map(item => item.id)) export const figmaHomeMenuItemIds = figmaHomeMenuSections.flatMap(section => section.items.map(item => item.id))
// Figma 최근파일 662:67331 sample rows (15) and the 2-depth preview 662:67471 (sample data only). // Figma 최근파일 662:67331 sample rows (15) and the 2-depth preview 662:67471 (sample data only; 2026-10-21: the preview shows the hovered file name over an empty black body).
const recentNames = [ const recentNames = [
["001-연결종합 계획평면도22222222.dwg", "a"], ["03.암발파공법적용도_231011.grm", "b"], ["1.배수공-일반사항_231004.dwg", "b"], ["001-연결종합 계획평면도.dwg", "a"], ["03.암발파공법적용도_231011.grm", "b"], ["1.배수공-일반사항_231004.dwg", "b"],
["00.시공상세도 분석 및 목차_20240416.grm"], ["0_목차.dwg"], ["001-연결종합 계획평면도22222222.dwg"], ["03.암발파공법적용도_231011.grm"], ["00.시공상세도 분석 및 목차_20240416.grm"], ["0_목차.dwg"], ["001-연결종합 계획평면도22222222.dwg"], ["03.암발파공법적용도_231011.grm"],
["001-연결종합 계획평면도22222222.dwg"], ["03.암발파공법적용도_231011.grm"], ["0_목차.dwg"], ["00.시공상세도 분석 및 목차_20240416.grm"], ["001-연결종합 계획평면도22222222.dwg"], ["03.암발파공법적용도_231011.grm"], ["0_목차.dwg"], ["00.시공상세도 분석 및 목차_20240416.grm"],
["001-연결종합 계획평면도22222222.dwg"], ["03.암발파공법적용도_231011.grm"], ["0_목차.dwg"], ["00.시공상세도 분석 및 목차_20240416.grm"], ["001-연결종합 계획평면도22222222.dwg"], ["03.암발파공법적용도_231011.grm"], ["0_목차.dwg"], ["00.시공상세도 분석 및 목차_20240416.grm"],
@@ -100,11 +99,9 @@ const monitor = { a: monitorA, b: monitorB }
export const figmaRecentFiles: SWFileEntry[] = recentNames.map(([name, badge], index) => ({ export const figmaRecentFiles: SWFileEntry[] = recentNames.map(([name, badge], index) => ({
id: `recent-${index + 1}`, id: `recent-${index + 1}`,
name, name,
path: index < 2 ? "Z:\\01.기술개발센터\\04_그리미시스템\\Design..." : "Z:\\01.기술개발센터\\04_그리미시스템\\D...", // Full folder (the list cuts it with CSS ellipsis); the preview shows this folder + the file name (2026-10-21).
path: "Z:\\01.기술개발센터\\04_그리미시스템\\Design\\도면",
badge: badge ? <img src={monitor[badge]} alt="" width={37} height={32} style={{ transform: "translateY(-2px)" }} /> : undefined, badge: badge ? <img src={monitor[badge]} alt="" width={37} height={32} style={{ transform: "translateY(-2px)" }} /> : undefined,
// Figma image 918 crop inside the 310 x 244 frame.
preview: <img src={previewImage} alt="" style={{ position: "absolute", left: "-25.97%", top: "-52.46%", width: "158.67%", height: "191.87%", maxWidth: "none", objectFit: "fill" }} />,
previewTitle: "C:/Users/user/Desktop/H1100.DWG",
})) }))
/** The Figma menu with the optional 최근파일 sub panel attached to the 최근파일 tile. */ /** The Figma menu with the optional 최근파일 sub panel attached to the 최근파일 tile. */
+1 -1
View File
@@ -192,7 +192,7 @@ function StackPage() {
description="Popup 안의 Escape는 그 Popup만 닫습니다. 바깥의 Escape는 가장 최근에 열리거나 사용한 Popup 하나만 닫습니다. Dropdown·편집 중인 Input이 처리한 Escape로는 닫히지 않습니다. 긴 콘텐츠는 본문이 스크롤됩니다." /> description="Popup 안의 Escape는 그 Popup만 닫습니다. 바깥의 Escape는 가장 최근에 열리거나 사용한 Popup 하나만 닫습니다. Dropdown·편집 중인 Input이 처리한 Escape로는 닫히지 않습니다. 긴 콘텐츠는 본문이 스크롤됩니다." />
{log} {log}
<SWTheme className="sw-story-popups" data-stack=""> <SWTheme className="sw-story-popups" data-stack="">
{open.includes("a") && <SWPopup title="Popup A" onClose={() => close("a")} data-popup="a"><SWPopupSection title="A"><SWPopupRow label="값"><SWHeaderInput value="A" aria-label="A 값" onCommit={next => record(`a=${next}`)} /></SWPopupRow></SWPopupSection></SWPopup>} {open.includes("a") && <SWPopup title="Popup A" onClose={() => close("a")} data-popup="a"><SWPopupSection title="A"><SWPopupRow label="값" field="none" readOnly><span data-readonly-value="a">A</span></SWPopupRow></SWPopupSection></SWPopup>}
{open.includes("b") && <SWPopup title="Popup B" onClose={() => close("b")} data-popup="b"><SWPopupSection title="B"> {open.includes("b") && <SWPopup title="Popup B" onClose={() => close("b")} data-popup="b"><SWPopupSection title="B">
<SWPopupPropertyRow property={layerProperty(layerB)} menus={MENUS} onPropertyChange={(_, value) => { setLayerB(value); record(`b.layer=${value}`) }} /> <SWPopupPropertyRow property={layerProperty(layerB)} menus={MENUS} onPropertyChange={(_, value) => { setLayerB(value); record(`b.layer=${value}`) }} />
</SWPopupSection></SWPopup>} </SWPopupSection></SWPopup>}
+1 -1
View File
@@ -96,7 +96,7 @@ export const ThemeInheritance: Story = {
layers={[{ id: "0", name: "0_Layer_Cludmark" }, { id: "1", name: "A-WALL" }]} /> layers={[{ id: "0", name: "0_Layer_Cludmark" }, { id: "1", name: "A-WALL" }]} />
</div> </div>
<div style={{ width: 1440, marginTop: 240 }}> <div style={{ width: 1440, marginTop: 240 }}>
<SWFooter categories={CATEGORIES} defaultCategoryId="modify" auxItems={AUX_ITEMS} onSlotAdd={() => {}} <SWFooter categories={CATEGORIES} defaultCategoryId="modify" auxItems={AUX_ITEMS} onSlotAdd={() => {}} slotGuide={{}}
command={<SWFooterCommandLine history={HISTORY} expanded />} /> command={<SWFooterCommandLine history={HISTORY} expanded />} />
</div> </div>
</div> </div>
+16 -4
View File
@@ -1,7 +1,7 @@
import { useRef, useState, type ReactNode } from "react" import { useRef, useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { import {
SWHeader, SWSelect, SWViewerButton, SWViewerField, SWViewerHeader, SWViewerHint, SWViewerInput, SWViewerMenu, SWViewerPanel, SWViewerSection, SWViewerSwitch, SWHeader, SWHeaderWindowControls, SWSelect, SWViewerButton, SWViewerField, SWViewerHeader, SWViewerHint, SWViewerInput, SWViewerMenu, SWViewerPanel, SWViewerSection, SWViewerSwitch,
type SWViewerHeaderGroup, type SWViewerMenuEntry, type SWViewerHeaderGroup, type SWViewerMenuEntry,
} from "@hm-design/sw" } from "@hm-design/sw"
import "@hm-design/sw/styles.css" import "@hm-design/sw/styles.css"
@@ -58,12 +58,21 @@ function AboutPanel({ demo }: { demo: ViewerDemo }) {
) )
} }
function ViewerScreen({ width = 1440, initialDocument = SAMPLES[1].name as string | null, open = [] as ("layers" | "options" | "properties")[] }) { function ViewerScreen({ width = 1440, initialDocument = SAMPLES[1].name as string | null, open = [] as ("layers" | "options" | "properties")[], windowControls = false }) {
const demo = useViewerDemo({ initialDocument, initialPanels: open }) const demo = useViewerDemo({ initialDocument, initialPanels: open })
// 2026-10-21 new viewer version: SWHeader's own minimize / close (SWHeaderWindowControls, same icons, size, hover)
// at the right end after the existing trailing items (the `trailing` slot, no new API). The program owns the real
// window actions (onMinimize / onClose, as in SWHeader); the Story only logs them.
const trailing = windowControls ? (
<>
{demo.trailing}
<SWHeaderWindowControls showWindowMove={false} onMinimize={() => demo.record("minimize")} onClose={() => demo.record("close")} />
</>
) : demo.trailing
return ( return (
<> <>
<Log lines={demo.log} /> <Log lines={demo.log} />
<ViewerFrame width={width} header={<SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}> <ViewerFrame width={width} header={<SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={trailing} onCommand={demo.onCommand} />}>
<LayerPanel demo={demo} strategy="absolute" /> <LayerPanel demo={demo} strategy="absolute" />
<OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} /> <OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} />
<PropertiesPanel demo={demo} strategy="absolute" inset={{ side: 24, top: 488 }} /> <PropertiesPanel demo={demo} strategy="absolute" inset={{ side: 24, top: 488 }} />
@@ -81,6 +90,9 @@ export const ViewerHeader: Story = {
<StorySection title="기본형 (1440px, 도면 열림)"> <StorySection title="기본형 (1440px, 도면 열림)">
<ViewerScreen /> <ViewerScreen />
</StorySection> </StorySection>
<StorySection title="최소화 · 닫기 버튼 (1440px, 도면 열림)" description="SWHeader의 최소화·닫기 버튼(SWHeaderWindowControls)을 trailing 오른쪽 끝에 그대로 둔 버전입니다. 실제 창 최소화·닫기는 프로그램이 처리합니다.">
<div data-viewer-window-controls=""><ViewerScreen windowControls /></div>
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
@@ -324,7 +336,7 @@ export const Responsive: Story = {
<StoryPageHeader kicker={KICKER} title="SW Viewer — 화면 너비" /> <StoryPageHeader kicker={KICKER} title="SW Viewer — 화면 너비" />
<Log lines={demo.log} /> <Log lines={demo.log} />
<div className="sw-story-stack"> <div className="sw-story-stack">
{[1440, 1100, 860, 520].map(width => ( {[1920, 1440, 1280, 950, 860, 760, 520].map(width => (
<StorySection key={width} title={`${width}px`}> <StorySection key={width} title={`${width}px`}>
<div className="sw-story-scroller sw-viewer-story-menu-row"><div style={{ width }} data-width={width}><SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div></div> <div className="sw-story-scroller sw-viewer-story-menu-row"><div style={{ width }} data-width={width}><SWViewerHeader documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div></div>
</StorySection> </StorySection>