docs(sw): AI rules, example E, tests and evidence for the popup / footer / header changes

AI documents (packages/sw/ai)
- SWPopupPropertyRow, SWSelect, SWSlider in the catalog, usage table,
  composition patterns and design rules; SWViewerSlider noted as SWSlider panel.
- Rule: the SWHeader layer / color dropdowns are header-only; popup layer /
  color / line style use SWPopupPropertyRow (property window pickers).
- Footer rules: gra-border-orange surfaces, Model / Layout text unchanged on
  hover, side button hover, vacant surface limited to the slot row.
- Header rules: measured More growing behavior (hysteresis fix A).
- Example E (custom popup) uses SWPopupPropertyRow, SWSelect and SWSlider
  instead of SWHeaderLayerMenu in the popup.

Tests (packages/sw/tests)
- New footer-popup-viewer-refinement.browser.mjs (47 checks: footer stories,
  hover / gradient / tab text / pocket, popup pickers vs property window rows,
  SWSlider, unit SWSelect parity, Popup B, header trace, viewer title art).
- integrated-popup / integrated-consumer: popup pickers and SWSelect instead of
  the header dropdown and the HM UI Select (same overlay / callback / Escape
  requirements).
- integrated-improvement: default sections skip the retired SW/Tool story
  ('tool' still runs by name); storybook-inventory / simplification record the
  retired footer IDs; theme-inheritance widths use sw-footer--full-footer.

Docs (docs/sw)
- sw-footer-popup-viewer-refinement-2026-10-20.md: causes, decisions (the
  first-pass "header dropdown in popup" change is marked withdrawn),
  verification and baseline failures; evidence footer-popup-viewer-refinement-qa
  (47/47). Footer mapping document updated for the removed sections.

Baseline failures (unchanged by this work): home-menu-integration 1,
storybook-unused-removal 5 (10-19 snapshot comparisons), range-cross-browser and
property-panel-comparison open stories retired on 2026-10-19.
This commit is contained in:
shkim4 committed 2026-10-20 15:20:27 +09:00
1 parent 126a35d60d
commit 454472d344
24 files changed
+1211 -52

No files matched your search

+2
View File
@@ -1,6 +1,8 @@
# @hm-design/sw
> **0.2.0 통합 개선(2026-10-19):** 패키지에 포함된 AI 안내 [ai/AI_USAGE.md](./ai/AI_USAGE.md)·[카탈로그](./ai/COMPONENT_CATALOG.md)가 공개 API의 기준입니다. 추가: `SWPopup` / `SWPopupSection` / `SWPopupRow`(속성창 외형 + `children` 자유 콘텐츠, 여러 Popup Escape 스택, Popup 안 Dropdown 고정 Overlay), `SWHeaderPropertyPanel`의 `children`·`title`·`autoFocus`와 선택형 `selection`/`properties`/`groups`, `SWToolButton` / `SWToolGroup`(기존 글자 토글·pill·파일명 면 재사용), `SWHeader`의 `documentOpenPlacement`(로고 오른쪽 Open, 실측 기반 More 이동)·`leading`, `hiddenControls`의 `documentNew`·`documentOpen`·`layerFreeze`·`layerLock`, `showNew`/`showOpen`/`showFreeze`/`showLock`. 모두 선택 Props이며 기존 화면은 그대로입니다. 빌드는 줄바꿈 정규화로 재현 가능하고 `dist/build-info.json`에 출처 Commit·도구 버전을 남깁니다. 검증 기록: 저장소 `docs/sw/sw-integrated-improvement-2026-10-19.md`.
>
> **2026-10-20:** `SWSlider` 추가(게이지바, `appearance` `"panel"` 기본 / `"property"` 속성창 모양, 값·이벤트 공통). `SWSelect`(native 선택 목록, Popup·Viewer 공통), `SWPopupPropertyRow`(Popup 안 레이어·색상 = 속성창 picker, SWHeader 필드 Dropdown은 Header 전용) 추가. SWHeader 넓어질 때 More 복귀 히스테리시스 수정(Open 재이동 없음). Footer Model·Layout 탭 주황 그라데이션·전체보기 Hover, 선택 분류 Pocket 면 통일. 뷰어 패널 312px 미만 폭의 제목 배경 누락 수정. 기록: 저장소 `docs/sw/sw-footer-popup-viewer-refinement-2026-10-20.md`.
> **로고 클릭 메뉴(2026-10-19):** `SWHomeMenu` / `SWHomeMenuSection` / `SWHomeMenuItem` / `SWRecentFiles`(Figma 26:10591 2단 Header 메뉴, 최근파일 하위 패널)와 `SWHeader`의 `homeMenu`·`homeMenuOpen`·`defaultHomeMenuOpen`·`onHomeMenuOpenChange`. 기본 숨김 → 로고 클릭 열기 → 다시 클릭 닫기, 위치는 Header DOM 측정. `homeMenu`가 있으면 로고 클릭은 `onHomeClick`을 호출하지 않습니다. 모두 선택 Props이며 주지 않으면 기존 화면 그대로입니다([카탈로그 5절](./ai/COMPONENT_CATALOG.md), [예제 G](./ai/examples/ExampleG-HomeMenu.tsx)). 검증 기록: 저장소 `docs/sw/sw-home-menu-integration-2026-10-19.md`.
+1 -1
View File
@@ -41,7 +41,7 @@ SW 패키지에는 Header·Popup 계열이 두 벌 있습니다. 화면마다 **
| 기준 | Figma 이지빔 SW `26:8944`, 49px | 참고 DWG/DXF 뷰어 실측(1440px), 52px |
| Header | `SWHeader`, 또는 `<SWTheme>` 안의 개별 컨트롤(`SWHeaderHome`, `SWHeaderDocumentTitle`, `SWHeaderLayerMenu`, `SWHeaderColorMenu`, `SWHeaderLineStyleMenu`, `SWHeaderTextStyleMenu`, `SWHeaderDimensionStyleMenu`, `SWHeaderActions`, `SWHeaderWindowControls`, `SWHeaderProperties` …) | `SWViewerHeader`(`groups` 데이터 + `trailing`), 오른쪽 끝·`custom` 버튼 `SWViewerHeaderButton`, 상태 `SWViewerStatus` |
| Dropdown | 각 필드 Dropdown(`SWHeaderLayerMenu` …) | `type: "menu"` 항목, 단독·우클릭은 `SWViewerMenu` |
| 팝업·설정창 | `SWPopup`(`titleActions`, `footer`) + `SWPopupSection` / `SWPopupRow` + `SWHeaderInput`, 속성창 `SWHeaderPropertyPanel`, 속성Bar `SWHeaderPropertyBar` | `SWViewerPanel`(이동·크기 조절·접기·`modal`, `headerActions`, `footer`) + `SWViewerSection` / `SWViewerField` / `SWViewerSwitch` / `SWViewerSlider` / `SWViewerInput` / `SWViewerButton` / `SWViewerBadge` / `SWViewerHint` |
| 팝업·설정창 | `SWPopup`(`titleActions`, `footer`) + `SWPopupSection` / `SWPopupRow` + `SWHeaderInput`, 레이어·색상 행 `SWPopupPropertyRow`(Header 필드 Dropdown은 Header 전용), 게이지바 `SWSlider`(`appearance`), 선택 목록 `SWSelect`, 속성창 `SWHeaderPropertyPanel`, 속성Bar `SWHeaderPropertyBar` | `SWViewerPanel`(이동·크기 조절·접기·`modal`, `headerActions`, `footer`) + `SWViewerSection` / `SWViewerField` / `SWViewerSwitch` / `SWViewerSlider` / `SWViewerInput` / `SWViewerButton` / `SWViewerBadge` / `SWViewerHint`, 선택 목록 `SWSelect`(뷰어 패널 토큰으로 표시) |
| 로고(홈) 버튼 메뉴 | `SWHeader`의 `homeMenu`(`SWHomeMenu` 데이터, 최근파일 `SWRecentFiles`, Figma 26:10591). 기본 숨김 → 로고 클릭 열기 → 다시 클릭 닫기 | **사용하지 않음.** `SWViewerHeader`에는 홈 메뉴 Props가 없고 로고 클릭은 `onHomeClick`만 호출합니다. 파일 열기·샘플은 Header `groups`의 `button`·`menu` 항목 |
| 하단 Footer | `SWFooter`(+ 바 안 명령창 `command={<SWFooterCommandLine />}`, 좌표 `status`, `SWFooterLayoutTabs`, 부품 `SWFooter*`), [FOOTER.md](./FOOTER.md) | **사용하지 않음.** 뷰어 화면에는 하단 명령 막대가 없습니다. 도면 공간 탭은 `SWViewerHeader`의 `tabs` 항목 |
| 버튼·탭·토글 | `SWToolButton` / `SWToolGroup` | Header 항목 `type: "button" \| "toggle" \| "tabs" \| "switch"`, 패널 안 `SWViewerButton` |
+6 -3
View File
@@ -8,7 +8,7 @@
- **공통 키보드:** Click·Enter·Space 열기/닫기(Hover로는 열리지 않음), ArrowDown 열고 첫(현재) 항목 Focus, Arrow 이동, Escape·바깥 클릭·Tab 이탈 닫기(Escape는 Trigger로 Focus 복귀).
- **검증 표시:** ✅ = 2026-10-19 독립 소비 프로젝트(Tarball 설치)에서 브라우저 검증, ◻ = 소비 검증 범위 밖(Storybook 검증 기록만 있음).
- **먼저 모드:** SW 일반 모드(편집) / SW 뷰어 모드(도면 보기)를 [AI_USAGE.md](./AI_USAGE.md) §0으로 정합니다. **뷰어 모드는 §9(`SWViewer*`)만** 쓰고, 아래 §1~8은 일반 모드용입니다. 두 모드를 한 화면에 섞지 않습니다.
- **무엇을 쓸까(일반 모드):** Header 전체 → `SWHeader`(1). Header 부품 조합 → 개별 컨트롤(2). 팝업·설정창·정보창 → `SWPopup`(3). 도구 막대·탭·토글 → `SWToolButton` / `SWToolGroup`(4). 로고 클릭 2단 메뉴·최근파일 → `SWHeader homeMenu` / `SWHomeMenu`(5). Slider·Switch·Badge·Progress·Dialog(모달)·Label·FilterBar는 SW에 없습니다 → `@hm-design/ui`를 그대로 씁니다(필요하면 `SWPopup` children 안에).
- **무엇을 쓸까(일반 모드):** Header 전체 → `SWHeader`(1). Header 부품 조합 → 개별 컨트롤(2). 팝업·설정창·정보창 → `SWPopup`(3). 도구 막대·탭·토글 → `SWToolButton` / `SWToolGroup`(4). 로고 클릭 2단 메뉴·최근파일 → `SWHeader homeMenu` / `SWHomeMenu`(5). 게이지바(투명도 등)는 `SWSlider`, 선택 목록(단위 등)은 `SWSelect`(3). Switch·Badge·Progress·Dialog(모달)·Label·FilterBar는 SW에 없습니다 → `@hm-design/ui`를 그대로 씁니다(필요하면 `SWPopup` children 안에).
- **무엇을 쓸까(일반 모드 하단):** 하단 명령 막대(보조기능·명령 칸·분류 탭·명령어 전체보기) → `SWFooter`, 도면 공간 탭(Model/Layout) → `SWFooterLayoutTabs`, 명령창 → `SWFooterCommandLine`(10, [FOOTER.md](./FOOTER.md)).
- **무엇을 쓸까(뷰어 모드):** Header → `SWViewerHeader`(`groups` + `trailing`), Dropdown·우클릭 → `SWViewerMenu`, 팝업·설정창·모달 → `SWViewerPanel` + `SWViewerSection`/`SWViewerField`/`SWViewerSwitch`/`SWViewerSlider`/`SWViewerInput`/`SWViewerButton`, 상태 → `SWViewerStatus`(9).
@@ -72,6 +72,9 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
| `SWPopup` | 설정·정보·사용자 정의 Popup 프레임 | `title`(필수), `children`, `onClose`(닫기 버튼·Escape), `closeLabel`(기본 `닫기`), `titleActions`(제목 막대 닫기 버튼 앞 추가 버튼), `footer`(본문 뒤 고정 영역, 본문이 스크롤해도 보임), `autoFocus`(열릴 때 Focus, 안에서 닫히면 원래 위치로 복귀. 기본: `anchorRef`가 있으면 true, 없으면 false), `anchorRef`(기준 요소 아래 고정 Overlay), `style`·`className`·`div` 속성 | 폭 314, 제목줄 30, 높이는 콘텐츠(최대 화면 − 8px, `style.maxHeight`로 더 줄일 수 있음) → 본문 스크롤. Non-modal(Dim·Portal·Focus trap 없음) | ✅ |
| `SWPopupSection` | 속성창 그룹 면(제목 + 내용) | `title`, `children`, `section` 속성 | 그룹 제목 700·70% 검정, 내용 면 `--hm-sw-property-content` | ✅ |
| `SWPopupRow` | 속성창 한 줄(라벨 + 값) | `label`(필수), `htmlFor`, `children`(값 컨트롤, SW 필드는 `SWHeaderInput`), `unit`, `field`(`box` 기본 · `compact` · `none`), `numeric`(숫자 편집 On), `readOnly`, `disabled` | 행 최소 32, 라벨 72, 값 상자 28(Default·Hover 테두리, 숫자 편집 시 #0F0D0A). `compact` = 41px hug 숫자 필드 | ✅ |
| `SWSlider` | 게이지바(native range, 2026-10-20). `SWPopupRow field="none"` 안이나 `SWTheme` 안 어디든 | `value`(필수), `min`(0)·`max`(100)·`step`(1), `onValueChange`(드래그·키보드마다), `onValueCommit`(값 확정 = native `change`), `appearance`(`"panel"` 기본 · `"property"`), `disabled`, `aria-label`, `input` 속성 | 모양만 다르고 값·이벤트·키보드는 같음. `panel` = 지금까지 Popup에서 쓰던 게이지바(6px 트랙 · 18px 흰 Thumb + 2px 녹색 테두리, `SWViewerSlider`와 같은 모양), `property` = 속성창 게이지바(베이지 트랙 · 빨강 채움 · Figma Thumb 14px). 폭은 행을 채움(호스트가 `style.width`로 지정 가능). Disabled 40% | ◻ |
| `SWPopupPropertyRow` | Popup 안 레이어·색상·선스타일 등 속성창 한 줄(2026-10-20) | `property`(`SWHeaderProperty`: `name`·`value`·`type`, 레이어는 `picker: "layer"` + `options`, 색상은 `type: "color"`), `menus`(`SWHeaderPropertyMenus`: `layers`·`colors`·…), `onPropertyChange(property, value)`, `className` | `SWHeaderPropertyPanel` 행과 같은 DOM·모양·동작(속성창 `picker`, 고정 Overlay 목록). **SWHeader 필드 Dropdown(`SWHeaderLayerMenu` 등)을 Popup에 넣지 않습니다** | ◻ |
| `SWSelect` | 선택 목록(native select, 2026-10-20). `SWPopupRow field="none"`, `SWViewerField`, `SWTheme` 안 어디든 | `value`/`defaultValue`, `options`(`SWSelectOption[]` = `{ value, label?, disabled? }`) 또는 `<option>` children, `onValueChange(value)`, `disabled`, `id`·`aria-label`, `select` 속성 | 28px, 좌우 8, 1px `#948871`, radius 4, 면 `#F1ECE1`, 12px 검정, 플랫폼 화살표·옵션 목록. Popup 도면 설정 단위 = Viewer 축척 지정 단위. Disabled 40% | ◻ |
| `SWHeaderPropertyPanel` | 데이터 기반 속성창(2절). 같은 프레임이며 `children`도 받음 | 2절 | 같은 DOM·class | ✅ |
- **Escape·Focus:** Popup 안의 Escape는 그 Popup만 닫습니다. 바깥의 Escape는 가장 최근에 열리거나 사용한(Focus·클릭) Popup 하나만 닫습니다. Dropdown·편집 중인 Input처럼 Escape를 이미 처리한 요소(`preventDefault` / 전파 중단)가 있으면 Popup은 닫히지 않습니다.
@@ -111,7 +114,7 @@ Figma 26:10591(home on), 타일 15종 26:11355 … 26:11631, 섹션 제목 26:12
## 6. 공개 타입
`SWHeaderProps`, 각 컴포넌트 Props(`SWHeaderHomeProps`, `SWHeaderDocumentTitleProps`, `SWHeaderLayerControlProps`, `SWHeaderLayerMenuProps`, `SWHeaderColorMenuProps`, `SWHeaderLineStyleMenuProps`, `SWHeaderTextStyleMenuProps`, `SWHeaderDimensionStyleMenuProps`, `SWHeaderInputProps`, `SWHeaderPropertiesProps`, `SWHeaderPropertyPanelProps`, `SWHeaderPropertyBarProps`, `SWHeaderActionsProps`, `SWHeaderWindowControlsProps`), `SWThemeProps`, `SWPopupProps`, `SWPopupSectionProps`, `SWPopupRowProps`, `SWToolButtonProps`, `SWToolButtonAppearance`, `SWToolGroupProps`, `SWHeaderMenuId`, `SWHeaderControlId`, `SWHeaderControlConfig`, `SWHeaderFrameControlId`, `SWHeaderSubControlId`, `SWHeaderHideableControlId`, `SWHeaderDocument`, `SWHeaderLayer`, `SWHeaderLayerValue`, `SWHeaderColorValue`, `SWHeaderColorOption`, `SWHeaderColorMode`, `SWHeaderColorInputValue`, `SWHeaderLineStyleValue`, `SWHeaderLineType`, `SWHeaderLineWeight`, `SWHeaderTextStyleValue`, `SWHeaderTextStyleOption`, `SWHeaderDimensionStyleValue`, `SWHeaderDimensionStyleOption`, `SWHeaderInputValueType`, `SWHeaderProperty`, `SWHeaderPropertyType`, `SWHeaderPropertyPicker`, `SWHeaderPropertyMenus`, `SWHeaderPropertyGroup`, `SWHeaderPropertySelection`, `SWHeaderPropertyShapeContent`, `SWHeaderPropertyShapeContext`, `SWHeaderHomeMenu`, `SWHeaderHomeMenuChangeReason`, `SWHomeMenuProps`, `SWHomeMenuSectionProps`, `SWHomeMenuItemProps`, `SWHomeMenuItemData`, `SWHomeMenuSectionData`, `SWHomeMenuSelectEvent`, `SWHomeMenuCloseReason`, `SWHomeMenuAccent`, `SWRecentFilesProps`, `SWFileEntry`. 뷰어 모드 타입은 §9.
`SWHeaderProps`, 각 컴포넌트 Props(`SWHeaderHomeProps`, `SWHeaderDocumentTitleProps`, `SWHeaderLayerControlProps`, `SWHeaderLayerMenuProps`, `SWHeaderColorMenuProps`, `SWHeaderLineStyleMenuProps`, `SWHeaderTextStyleMenuProps`, `SWHeaderDimensionStyleMenuProps`, `SWHeaderInputProps`, `SWHeaderPropertiesProps`, `SWHeaderPropertyPanelProps`, `SWHeaderPropertyBarProps`, `SWHeaderActionsProps`, `SWHeaderWindowControlsProps`), `SWThemeProps`, `SWPopupProps`, `SWPopupSectionProps`, `SWPopupRowProps`, `SWPopupPropertyRowProps`, `SWSliderProps`, `SWSliderAppearance`(`"panel"` / `"property"`), `SWSelectProps`, `SWSelectOption`, `SWToolButtonProps`, `SWToolButtonAppearance`, `SWToolGroupProps`, `SWHeaderMenuId`, `SWHeaderControlId`, `SWHeaderControlConfig`, `SWHeaderFrameControlId`, `SWHeaderSubControlId`, `SWHeaderHideableControlId`, `SWHeaderDocument`, `SWHeaderLayer`, `SWHeaderLayerValue`, `SWHeaderColorValue`, `SWHeaderColorOption`, `SWHeaderColorMode`, `SWHeaderColorInputValue`, `SWHeaderLineStyleValue`, `SWHeaderLineType`, `SWHeaderLineWeight`, `SWHeaderTextStyleValue`, `SWHeaderTextStyleOption`, `SWHeaderDimensionStyleValue`, `SWHeaderDimensionStyleOption`, `SWHeaderInputValueType`, `SWHeaderProperty`, `SWHeaderPropertyType`, `SWHeaderPropertyPicker`, `SWHeaderPropertyMenus`, `SWHeaderPropertyGroup`, `SWHeaderPropertySelection`, `SWHeaderPropertyShapeContent`, `SWHeaderPropertyShapeContext`, `SWHeaderHomeMenu`, `SWHeaderHomeMenuChangeReason`, `SWHomeMenuProps`, `SWHomeMenuSectionProps`, `SWHomeMenuItemProps`, `SWHomeMenuItemData`, `SWHomeMenuSectionData`, `SWHomeMenuSelectEvent`, `SWHomeMenuCloseReason`, `SWHomeMenuAccent`, `SWRecentFilesProps`, `SWFileEntry`. 뷰어 모드 타입은 §9.
## 7. 공개하지 않은 것 (사용 금지)
@@ -142,7 +145,7 @@ SW **뷰어 모드** 전용 Export입니다(2026-10-19 추가, 참고 DWG/DXF
| `SWViewerSection` | 패널 콘텐츠 묶음(12px 700 제목) | `title`, `surface`(`card` 기본 / `plain`), `children`, `section` 속성 | ◻ |
| `SWViewerField` | 라벨 + 컨트롤 행 | `label`(필수), `children`(노드 또는 `(id) => 노드`), `htmlFor`, `layout`(`inline` 기본 / `stacked`), `unit`, `below`, `disabled` | ◻ |
| `SWViewerSwitch` | 스위치 | `checked`(필수), `onCheckedChange`, `button` 속성 | ◻ |
| `SWViewerSlider` | 슬라이더(native range) | `value`·`min`·`max`(필수), `step`, `onValueChange`, `input` 속성 | ◻ |
| `SWViewerSlider` | 슬라이더(native range). 내부는 `SWSlider appearance="panel"` + 뷰어 패널 토큰(2026-10-20, 모양 동일) | `value`·`min`·`max`(필수), `step`, `onValueChange`, `input` 속성 | ◻ |
| `SWViewerInput` | 28px 패널 Input(native input) | `input` 속성 전체 | ◻ |
| `SWViewerButton` | 패널 버튼 | `variant`(`outline` / `primary` / `ghost`), `tone`(`"green"`), `icon`, `button` 속성 | ◻ |
| `SWViewerBadge` | 개수 Badge | `children`(필수), `span` 속성 | ◻ |
+6 -1
View File
@@ -87,7 +87,7 @@
1. 카탈로그 3절에서 `SWPopup`을 찾는다(프레임 = 속성창 외형·닫기·Escape·스크롤).
2. 내용은 `children`으로 구성한다. 행 모양은 `SWPopupSection` + `SWPopupRow`, 값 입력은 `SWHeaderInput`.
3. SW에 없는 컨트롤(Switch, Slider, Select, Button 등)은 `@hm-design/ui`를 `children` 안에 그대로 둔다. 구조 제한은 없다.
3. 레이어·색상·선스타일은 `SWPopupPropertyRow`(속성창 `picker`)로 둔다. SWHeader 필드 Dropdown(`SWHeaderLayerMenu`·`SWHeaderColorMenu`)은 Header 전용이라 Popup에 넣지 않는다. 게이지바는 `SWSlider`(`appearance` `"panel"` 기본 / `"property"` 속성창 모양), 선택 목록은 `SWSelect`를 `SWPopupRow field="none"`에 둔다. SW에 없는 컨트롤(Switch, Button 등)은 `@hm-design/ui`를 `children` 안에 그대로 둔다. 구조 제한은 없다.
4. 프레임(폭, 제목줄, 닫기, 표면색)을 다시 만들지 않고, `hm-sw-property*` class를 쓰지 않는다.
```tsx
@@ -97,6 +97,11 @@
<SWPopupRow label="이름" htmlFor={id}><SWHeaderInput id={id} value={name} onCommit={setName} /></SWPopupRow>
<SWPopupRow label="축척" unit="%" numeric><SWHeaderInput aria-label="축척" valueType="number" value={scale} onCommit={setScale} /></SWPopupRow>
</SWPopupSection>
<SWPopupSection title="표시">
<SWPopupRow label="단위" htmlFor={unitId} field="none"><SWSelect id={unitId} value={unit} options={[{ value: "mm" }, { value: "m" }]} onValueChange={setUnit} /></SWPopupRow>
<SWPopupPropertyRow property={{ id: "layer", name: "레이어", value: layerId, type: "select", groupId: "g", picker: "layer", options }} menus={{ layers }} onPropertyChange={(_, id) => setLayerId(id)} />
<SWPopupRow label="투명도" field="none" unit="%"><SWSlider aria-label="투명도" value={opacity} onValueChange={setOpacity} onValueCommit={applyOpacity} /></SWPopupRow>
</SWPopupSection>
<MyProjectFields />
</SWPopup>
</SWTheme>
+12 -8
View File
@@ -84,7 +84,7 @@
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이 가장 먼저 옮겨집니다. 메뉴 열기·닫기는 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로 옮기지 않으므로 폭이 좁은 화면에는 많이 넣지 않습니다.
로고 클릭 메뉴는 좁은 폭에서는 가로 스크롤 없이 들어가는 섹션만 앞에서부터 보여 주고, 나머지는 끝의 더보기(⋯) 섹션으로 옮깁니다(2026-10-19 사용자 확정, Figma에 좁은 폭 디자인 없음). 더보기를 누르면 옮긴 섹션이 텍스트 메뉴(섹션 제목 + 기능 텍스트 목록, 그룹 사이 구분선)로 아래 Dropdown에 열리고, 폭이 넓어지면 원래 자리로 돌아갑니다. 화면에 남은 섹션은 Figma 타일 그대로입니다(§12).
@@ -102,11 +102,14 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
| 본문 | padding 7px, 그룹 사이 8px | — |
| 본문 스크롤바 | `thin`, Thumb `#9A9284` / Track `#E0DACE`(DWG/DXF Viewer 패널과 동일). 본문과 그 안의 스크롤 영역에 적용, 페이지 스크롤바는 그대로 | `--hm-sw-popup-scrollbar-thumb`, `--hm-sw-popup-scrollbar-track` |
| 그룹 면(`SWPopupSection`) | 제목 700 · 검정 70%, 내용 면 `#F1ECE1`, 1px `#585349`, radius 4px | `--hm-sw-property-content` |
| 속성 행(`SWPopupPropertyRow`) | 속성창 행 그대로(라벨 72px 700, 값 상자 28px, `picker` 필드). 데이터 = `SWHeaderProperty` + `menus` | `--hm-sw-property-*`, `--hm-sw-property-field-*` |
| 행(`SWPopupRow`) | 최소 32px, 라벨 72px 700 검정, 값 글자 `#E60403` 500, 단위 12px | `--hm-sw-property-label`, `--hm-sw-header-input-color` |
| 값 상자 | 28px, Hover 1px `rgba(33,29,19,.4)`, 숫자 편집 On `#0F0D0A`(단위 흰색), Dropdown On `#211D13` | `--hm-sw-property-field-*` |
| 동작 | Non-modal(Dim·Portal·Focus trap 없음). Escape는 Popup 안이면 그 Popup, 밖이면 최근 사용 Popup 하나. `autoFocus`면 열릴 때 Focus, 안에서 닫히면 원래 위치로 | — |
- 밝은 Popup 면에는 SW Header의 어두운 컨트롤(필드·`SWToolButton`)이 디자인상 맞지 않습니다. 쓸 수는 있지만(Dropdown은 고정 Overlay로 잘리지 않음) 행 모양이 필요하면 `SWPopupRow` + `SWHeaderInput` 또는 속성창 `picker`를 씁니다.
- **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`.
- 게이지바는 `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` 등 기존 컴포넌트를 쓰거나 사용자에게 디자인을 확인합니다.
## 9. 도구 버튼 (`SWToolButton` / `SWToolGroup`)
@@ -183,21 +186,22 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
|---|---|---|
| 바 | 높이 72, 배경 `#060606` | `--hm-sw-footer-height`, `-background` |
| 배치(1920, Figma) | 보조기능 버튼 0~74 → 2 → 보조기능 격자 76~392(위 2) → 4 → 명령 그룹 396~(위 1) → 명령어 전체보기(명령 칸 바로 뒤, 1552~1626) → 나머지 bg_vacant | — |
| 측면 버튼 | 74 × 71, 자산 면(그라데이션 + 그라데이션 테두리), 그림자 8px 60% 검정(왼쪽 버튼 → 오른쪽 5px, 오른쪽 버튼 → 왼쪽 5px). 제목 14px 1.3px 외곽선, 부제 14px 1px 외곽선, 위 18 · 줄 간격 2 | `--hm-sw-footer-side-*` |
| 측면 버튼 | 74 × 71, 자산 면(그라데이션 + 그라데이션 테두리), 그림자 8px 60% 검정(왼쪽 버튼 → 오른쪽 5px, 오른쪽 버튼 → 왼쪽 5px). 제목 14px 1.3px 외곽선, 부제 14px 1px 외곽선, 위 18 · 줄 간격 2. Hover·Pressed·Expanded = 제목·부제 `#FF5C00` + 1px 주황 그라데이션 테두리(`--hm-sw-footer-gra-border-orange`), Disabled 기본 모양 | `--hm-sw-footer-side-*` |
| 보조기능 | 104 × 33, gap 2, radius 3, 면 `linear-gradient(0deg, #262218 8%, #4A3C2B 34%, #594C31 55%, #312A1B 90%)`, 아이콘 중심 (19.5, 16.5), 라벨 13px 왼쪽 36 폭 57 가운데. 테두리 1px 그라데이션(위 → 아래): Default `#886D35 0%, #423625 8.85%, #F3DBA8 26.17%, #0E0B06 83.79%, #574B30 100%`(Figma btn_* 26:9248 …) | `--hm-sw-footer-aux-*`, `--hm-sw-footer-aux-border` |
| 보조기능 상태 | Hover(선택 아님) = 테두리만 노랑 `--hm-sw-header-hover-border-gradient`(`#A67D03 → #F2CC0F → #FADD4F → #F2CC0F → #926E03`, SW Header Figma Hover 재사용 — 버튼 세트에는 Hover 없음, 사용자 확정 2026-10-20). Selected(`aria-pressed="true"`, Figma Variant2 = Variant3) = 테두리 `#DA6813 8.52%, #FFBB89 37.93%, #E5690D 71.85%, #AE3607 92.77%` + 아이콘 채움·글자 `#FF5C00`, Hover보다 우선. 아이콘 외곽선 검정 90% 1px는 모든 상태 동일 | `--hm-sw-footer-aux-hover-border`, `--hm-sw-footer-aux-pressed-border`, `--hm-sw-footer-aux-icon-color`(`#F1EDE5`) |
| 명령 그룹 면 | 테두리 위·왼쪽·아래 1px `#886D35`, 왼쪽 위 radius 4, 면 `linear-gradient(0deg, #2F281A 0%, #312A1B 5%, #332B1C 36%, #463C29 58%, #38301F 79%, #19160E 98%)`. 마지막 명령 뒤(빈 칸·나머지)는 bg_vacant `linear-gradient(0deg, #18150E 28%, #262015 41%, #332B1C 58%, #38301F 81%, #18150E 100%)` | `--hm-sw-footer-group-*`, `--hm-sw-footer-vacant-background` |
| 보조기능 상태 | Hover(선택 아님) = 테두리만 노랑 `--hm-sw-header-hover-border-gradient`(`#A67D03 → #F2CC0F → #FADD4F → #F2CC0F → #926E03`, SW Header Figma Hover 재사용 — 버튼 세트에는 Hover 없음, 사용자 확정 2026-10-20). Selected(`aria-pressed="true"`, Figma Variant2 = Variant3) = 테두리 `#DA6813 8.52%, #FFBB89 37.93%, #E5690D 71.85%, #AE3607 92.77%`(Figma `gra-border-orange` = `--hm-sw-footer-gra-border-orange`) + 아이콘 채움·글자 `#FF5C00`, Hover보다 우선. 아이콘 외곽선 검정 90% 1px는 모든 상태 동일 | `--hm-sw-footer-aux-hover-border`, `--hm-sw-footer-aux-pressed-border`, `--hm-sw-footer-aux-icon-color`(`#F1EDE5`) |
| 명령 그룹 면 | 테두리 위·왼쪽·아래 1px `#886D35`, 왼쪽 위 radius 4, 면 `linear-gradient(0deg, #2F281A 0%, #312A1B 5%, #332B1C 36%, #463C29 58%, #38301F 79%, #19160E 98%)`. 마지막 명령 뒤(빈 칸·나머지)는 bg_vacant `linear-gradient(0deg, #18150E 28%, #262015 41%, #332B1C 58%, #38301F 81%, #18150E 100%)`, 명령 칸 높이까지만(2026-10-20: 분류 행 아래로 내려가지 않아 선택 탭 Pocket 면이 어느 탭에서나 같음) | `--hm-sw-footer-group-*`, `--hm-sw-footer-vacant-background` |
| 명령 칸 | 68 × 47, 라벨 14px `#F6F4F2` 위 6, 아이콘 18 × 18 위 26 가운데. 칸 사이 구분선 1 × 37(위 5) `linear-gradient(0deg, #20190E, #8A7A5B 45%, #F3DBA8 70%, #423625)` + `-1px 0 1px #000` | `--hm-sw-footer-slot-*`, `--hm-sw-footer-divider*` |
| 명령 상태 | Hover = 선택(Figma Variant2 = Variant3): 글자 `#FF5C00` + 주황 아이콘. Disabled 40%(Figma 없음) | `--hm-sw-footer-accent` |
| 빈 칸 | 68 × 46(위 1), + `#BEB5A6`. Hover: 주황 + 와 주황 그라데이션 점선 링(자산) | — |
| 가이드 | 208 × 149, 빈 칸 위 4px 가운데, 1px `#DA6813` radius 2, 면 `#0E3C2E`, 미리보기 194 × 111(5, 5, 검정 1px), 제목 14px 외곽선, 캡션 12px `#B6D0C9` 1px 외곽선 | `--hm-sw-footer-guide-*` |
| 분류 행 | 높이 24, 면 `#282216` → 위 14% `#574B30`, 안쪽 1px 테두리 그라데이션, 현재 탭 Pocket(좌우 7px 곡선, 아래 radius 2, 가장자리 `#675A3B`). 탭 14px `#D4CBBD`, 자간 10%, 위 4, 좌우 padding 8, 최소 43. 구분선 1 × 10 검정(위 7). Hover·현재 `#FF5C00` | `--hm-sw-footer-menu-*` |
| Layout 탭 | 높이 22, 12px 자간 2%. Default 면 `#C3B79E` 글자 `#161410`. On: `linear-gradient(0deg, #7C6C4C, #7C6C4C 85%, #A6946F)` 흰 글자. 이름 편집: `#0F0D0A` 빨강 `#E60403` 주황 테두리. Hover·On·편집 글자 아래 70% 검정 1.3px 외곽선. 둥근 탭: 1px `rgba(0,0,0,.05)`(Hover·편집 `#DA6813`), 위 radius 4, 안쪽 그림자 `inset 0 -3px 4px rgba(56,48,31,.25)`, ×(16) Hover 노랑. 사선 탭(마지막): 97 × 22 자산, Hover = 주황 면 + 검정 글자, On hover = 베이지 면 + 주황 글자, ×(28, 빨강 사선) | `--hm-sw-footer-tab-*` |
| Model 탭 | 73 × 22 자산(왼쪽 사선), 글자 왼쪽 22, Hover 주황, On 갈색. 앞 사선 탭과 8px 겹침 | — |
| Layout 탭 | 높이 22, 12px 자간 2%. Default 면 `#C3B79E` 글자 `#161410`. On: `linear-gradient(0deg, #7C6C4C, #7C6C4C 85%, #A6946F)` 흰 글자. 이름 편집: `#0F0D0A` 빨강 `#E60403` 주황 테두리. Hover·On·편집 글자 아래 70% 검정 1.3px 외곽선. 둥근 탭: 1px `rgba(0,0,0,.05)`(Hover·편집 `#DA6813`), 위 radius 4, 안쪽 그림자 `inset 0 -3px 4px rgba(56,48,31,.25)`, ×(16) Hover 노랑. 사선 탭(마지막): 97 × 22 자산, On hover = 베이지 면 + 주황 글자, ×(28, 빨강 사선). Hover(선택 아님, 두 모양 모두, 2026-10-20) = Model과 같은 `gra-border-orange` 면(사선 탭은 자산 모양 안)만 바뀌고 글자는 Default 그대로(`#161410` 12px 400, 외곽선 없음, 위치·크기 불변) | `--hm-sw-footer-tab-*`, `--hm-sw-footer-gra-border-orange` |
| `+` 버튼 | Layout 옆 `+`(24 × 22) Hover = `gra-border-orange` 면 + `+`(분류 행 `+` 자산). 분류 행 `+`(28 × 12) Hover = `+` 채움 `#FF5C00`(검정 외곽선 그대로) | `--hm-sw-footer-gra-border-orange`, `--hm-sw-footer-accent` |
| Model 탭 | 73 × 22 자산(왼쪽 사선), 글자 왼쪽 22. Hover·On = 자산 모양 안 `gra-border-orange` 면 + 검정 `#161410` 글자(12px 400, 외곽선 없음)(2026-10-20 사용자 확정, 이전 Figma On 갈색·흰 글자 대체). 앞 사선 탭과 8px 겹침 | `--hm-sw-footer-gra-border-orange` |
| 명령창 | 566 × 71 / 270, 위 radius 4, 위 71px `linear-gradient(180deg, #635537, #262218 5%, #281F16 10%, #282216)` + 아래 `#282216`. 12px 자간 10% 줄 14, 1px 외곽선. 입력 칸 23(좌우 4) `#16130D` + `0 -1px 2px rgba(255,255,255,.25)`, `inset 0 1px 4px #000`. 화살표 18 × 18 검정 radius 2(4, 4 오른쪽 위) | `--hm-sw-footer-command-*` |
| 바 안 명령창(실제 화면) | 보조기능 뒤 4px, 바 아래에 붙어 펼치면 위로 270. 남는 폭(바 − 보조기능 영역 − 명령어 전체보기)을 칸 영역 0.57 : 명령창 나머지(최소 280)로 나눔 — 칸 수는 바 폭으로만 계산, 분류의 명령 수와 무관(1920 → 12칸) | `--hm-sw-footer-command-min-width`, `--hm-sw-footer-slot-share` |
| 좌표(실제 화면) | 바 위 왼쪽, 12px `#F6F4F2` 1px 외곽선, 아래 3px | — |
| 좁은 폭(실제 화면) | 빈 칸이 먼저 줄고, 넘치는 명령은 마지막 칸 ▲(ico_arrow 18 × 18, 오른쪽 위 4) → 명령 칸 위 패널(같은 칸, 오른쪽 맞춤, 테두리 1px `#886D35`·아래 없음·위 radius 4, 명령 칸 면). 분류 탭 넘침 = 탭 영역 잘림 + ◀ ▶(13 × 16, 6 × 8 삼각형 `#F6F4F2`, Hover `#FF5C00`) + +. 바 1279px 이하 보조기능 아이콘만 45 × 33 | `--hm-sw-footer-aux-compact-width` |
- Figma에 없는 값: 실제 화면에서 가져온 값(바 안 명령창 배치, 좌표, ▲ 패널, 분류 ◀ ▶, 보조기능 아이콘만)과 AI 구성 값 — 디자인 확인 필요(측면 버튼 Hover 글자 `#FF5C00`, Disabled 40%, 명령창 최소 280px, 칸 영역 비율 0.57(두 실제 화면 재현 값), 보조기능 아이콘 전환 1279px, Layout 탭 이름 편집 입력의 동작).
- Figma에 없는 값: 실제 화면에서 가져온 값(바 안 명령창 배치, 좌표, ▲ 패널, 분류 ◀ ▶, 보조기능 아이콘만)과 AI 구성 값 — 디자인 확인 필요(측면 버튼 Hover 글자 `#FF5C00`·주황 테두리, 분류 행 `+` Hover, Disabled 40%, 명령창 최소 280px, 칸 영역 비율 0.57(두 실제 화면 재현 값), 보조기능 아이콘 전환 1279px, Layout 탭 이름 편집 입력의 동작).
- 새 하단 UI는 위 토큰을 `var(--…)`로 읽고 `hm-sw-footer*` class를 쓰거나 덮어쓰지 않습니다.
+5 -4
View File
@@ -1,6 +1,6 @@
# SW Footer — `SWFooter` 계열 (SW 일반 모드)
SW 프로그램 하단 명령 막대입니다. 기준은 Figma 이지빔-SW `레이아웃 분리`(26:10097) 하단 72px과 부품 노드 34개(Plugin API로 읽은 값, 2026-10-20)이고, 바 안 명령창·좌표·좁은 폭 동작은 사용자 제공 실제 화면(2026-10-20)을 따릅니다. 두 기준이 다르면 Figma가 우선입니다(예: 선택된 Model 탭은 Figma On 갈색). **SW 일반 모드(편집 화면) 전용**이며 뷰어 모드(`SWViewer*`) 화면에는 쓰지 않습니다([AI_USAGE.md](./AI_USAGE.md) §0).
SW 프로그램 하단 명령 막대입니다. 기준은 Figma 이지빔-SW `레이아웃 분리`(26:10097) 하단 72px과 부품 노드 34개(Plugin API로 읽은 값, 2026-10-20)이고, 바 안 명령창·좌표·좁은 폭 동작은 사용자 제공 실제 화면(2026-10-20)을 따릅니다. 두 기준이 다르면 Figma가 우선입니다. 단, 2026-10-20 사용자 확정으로 선택된 Model 탭과 Layout 탭 Hover는 주황 그라데이션(Figma `gra-border-orange`)입니다. **SW 일반 모드(편집 화면) 전용**이며 뷰어 모드(`SWViewer*`) 화면에는 쓰지 않습니다([AI_USAGE.md](./AI_USAGE.md) §0).
컴포넌트는 **표시와 사용자 입력 전달**만 합니다. CAD 명령 실행, 명령 등록, 레이아웃 생성·삭제, 명령 해석은 소비 프로그램이 Callback을 받아 처리하고 Props를 갱신합니다.
@@ -80,12 +80,13 @@ import "@hm-design/sw/styles.css"
| 명령 | Default `#F6F4F2` / Variant2 Hover = Variant3 선택: `#FF5C00` + 주황 아이콘 | Hover는 CSS, 선택은 `activeToolId`·`pressed` |
| 분류 탭 | Default `#D4CBBD` / Hover·현재 `#FF5C00`, 현재 탭은 Pocket | `categoryId`·`value` |
| 빈 칸 | Default + / Hover 주황 점선 링 + 주황 + | Hover·Focus 시 `slotGuide` 표시 |
| Layout 탭 | Default 베이지 `#C3B79E` / Hover(×) / On 갈색 그라데이션·흰 글자 / On hover / Variant5 이름 편집(`#0F0D0A`, 빨강 `#E60403`) / Variant6 편집 + Hover | `value`, 편집 = 더블클릭·F2 또는 `editingId` |
| Model 탭 | Default / Hover 주황 / On 갈색 | `value === SW_FOOTER_MODEL` |
| 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` |
| `+` 버튼 | Layout 옆 `+` Hover = 같은 주황 그라데이션 면 + `+`. 분류 행 `+` Hover = `+` `#FF5C00` | `onAdd` / `onCategoryAdd` |
| 명령창 화살표 | Default ↑ / Hover 주황 ↑ / On ↓ / On hover 주황 ↓ | `expanded` |
| Disabled | Figma 없음 | SW 공통 40%(명령·빈 칸·탭·보조기능) |
| 보조기능 | Figma btn_객체스냅 26:9248 … btn_선가중치 26:9412: Default 갈색 그라데이션 테두리·아이콘 `#F1EDE5`·글자 `#F6F4F2` / Variant2 = Variant3 주황 그라데이션 테두리·아이콘·글자 `#FF5C00` | Default 그대로. **Hover = 노랑 테두리만**(SW Header Figma Hover 토큰 `--hm-sw-header-hover-border-gradient`, 1px, 아이콘·글자 기본색). **Selected(`pressed`) = 주황 테두리 + 주황 아이콘 + 주황 글자 동시**, Hover 중에도·마우스가 벗어나도 유지. 크기·위치 변화 없음 |
| 측면 버튼 Hover | Figma 없음 | Footer Hover 규칙 재사용: 글자 `#FF5C00` (디자인 확인 필요) |
| 측면 버튼 Hover·Pressed | Figma 없음 | Footer 규칙 재사용(2026-10-20): 제목·부제 모두 `#FF5C00` + 테두리 주황 그라데이션(보조기능 Selected 테두리). 크기·면·그림자 그대로, Disabled는 기본 모양 (디자인 확인 필요) |
### 4.1 좁은 폭 (Figma 없음, 실제 화면 2026-10-20)
+1 -1
View File
@@ -71,7 +71,7 @@
| `autoFocus?` | 열 때 패널로 Focus, 닫을 때 원래 위치로 복귀(기본 true) |
- Escape는 Focus가 패널 안에 있을 때 그 패널만 닫습니다. 비모달 패널은 바깥 클릭으로 닫히지 않습니다(참고 뷰어와 동일, 도구 창). 마지막으로 누른 패널이 맨 앞으로 옵니다.
- **콘텐츠 우선순위:** `children`·`footer`는 타입상 `ReactNode`이고 소스 주석도 자유 콘텐츠(“any component”)라고 적습니다. 그래도 기본은 `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`·`SWViewerBadge`·`SWViewerHint` 우선입니다. 이것으로 안 되는 컨트롤(예: Select·Checkbox·표)만 `@hm-design/ui` 컴포넌트나 소비 프로젝트 요소를 넣을 수 있습니다. `@hm-design/ui`는 SW 패키지 dependency가 아니어서 별도 설치(사용 유형 혼합)가 필요하고, `SWPopup`과 달리 뷰어 패널 안 `@hm-design/ui` 외형 정합성은 문서화·검증 기록이 없으므로 결과 보고에 “디자인 확인 필요”로 적습니다. Header 안에는 `@hm-design/ui` DropdownMenu 등을 직접 두지 않습니다(§1 표).
- **콘텐츠 우선순위:** `children`·`footer`는 타입상 `ReactNode`이고 소스 주석도 자유 콘텐츠(“any component”)라고 적습니다. 그래도 기본은 `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`·`SWViewerBadge`·`SWViewerHint`와 선택 목록 `SWSelect`(축척 지정 단위, 뷰어 패널 토큰으로 표시) 우선입니다. 이것으로 안 되는 컨트롤(예: Select·Checkbox·표)만 `@hm-design/ui` 컴포넌트나 소비 프로젝트 요소를 넣을 수 있습니다. `@hm-design/ui`는 SW 패키지 dependency가 아니어서 별도 설치(사용 유형 혼합)가 필요하고, `SWPopup`과 달리 뷰어 패널 안 `@hm-design/ui` 외형 정합성은 문서화·검증 기록이 없으므로 결과 보고에 “디자인 확인 필요”로 적습니다. Header 안에는 `@hm-design/ui` DropdownMenu 등을 직접 두지 않습니다(§1 표).
- **콘텐츠 교체:** 같은 프레임에 다른 `children`을 넣습니다(레이어 목록 / 옵션 / 속성 / 입력 폼 — Storybook `Popup Content Variants`). 프레임을 복사하거나 `hm-sw-viewer-*` class로 새 팝업을 만들지 않습니다.
## 5. 올바른 사용
@@ -1,8 +1,9 @@
// Example E — the SW popup frame with project content (no Header). SWPopup keeps the property window design;
// children are free: SWPopupSection / SWPopupRow rows with SWHeaderInput, and an existing SW dropdown.
// children are free: SWPopupSection / SWPopupRow rows with SWHeaderInput, the unit SWSelect, the SWSlider gauge and the
// popup layer / color pickers (SWPopupPropertyRow). The SWHeader field dropdowns are header-only: never in a popup.
import { useId, useState } from "react"
import { SWHeaderLayerMenu, SWHeaderInput, SWPopup, SWPopupRow, SWPopupSection, SWTheme } from "@hm-design/sw"
import { layers } from "./sample-data"
import { SWHeaderInput, SWPopup, SWPopupPropertyRow, SWPopupRow, SWPopupSection, SWSelect, SWSlider, SWTheme } from "@hm-design/sw"
import { colors, layers } from "./sample-data"
export function ExampleECustomPopup({ onEvent }: { onEvent?: (name: string) => void }) {
const [open, setOpen] = useState(true)
@@ -10,7 +11,11 @@ export function ExampleECustomPopup({ onEvent }: { onEvent?: (name: string) => v
const [name, setName] = useState("평면도-01")
const [scale, setScale] = useState("100")
const [x, setX] = useState("1200.5")
const unitId = useId()
const [unit, setUnit] = useState("mm")
const [opacity, setOpacity] = useState(60)
const [activeLayerId, setActiveLayerId] = useState("0")
const [color, setColor] = useState("255,0,0")
if (!open) return <button type="button" onClick={() => setOpen(true)}>설정 열기</button>
return (
<SWTheme>
@@ -22,6 +27,12 @@ export function ExampleECustomPopup({ onEvent }: { onEvent?: (name: string) => v
<SWPopupRow label="축척" unit="%" numeric>
<SWHeaderInput aria-label="축척" valueType="number" value={scale} onCommit={next => { setScale(next); onEvent?.(`scale:${next}`) }} />
</SWPopupRow>
<SWPopupRow label="단위" htmlFor={unitId} field="none">
<SWSelect id={unitId} value={unit} options={[{ value: "mm" }, { value: "cm" }, { value: "m" }]} onValueChange={next => { setUnit(next); onEvent?.(`unit:${next}`) }} />
</SWPopupRow>
<SWPopupRow label="투명도" field="none" unit="%">
<SWSlider aria-label="투명도" value={opacity} step={10} onValueChange={setOpacity} onValueCommit={next => onEvent?.(`opacity:${next}`)} />
</SWPopupRow>
</SWPopupSection>
<SWPopupSection title="객체 정보">
<SWPopupRow label="타입" readOnly><SWHeaderInput aria-label="타입" value="Line" readOnly /></SWPopupRow>
@@ -29,10 +40,12 @@ export function ExampleECustomPopup({ onEvent }: { onEvent?: (name: string) => v
<SWHeaderInput aria-label="X" valueType="number" value={x} onCommit={next => { setX(next); onEvent?.(`x:${next}`) }} />
</SWPopupRow>
</SWPopupSection>
<SWPopupSection title="레이어">
{/* Inside a popup the dropdown opens as a fixed overlay, so the scrolling body never clips it. */}
<SWHeaderLayerMenu value={{ name: "0" }} layers={layers} activeLayerId={activeLayerId}
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }} />
<SWPopupSection title="레이어 · 색상">
{/* The property window pickers; their lists open as a fixed overlay, so the scrolling body never clips them. */}
<SWPopupPropertyRow property={{ id: "layer", name: "레이어", value: activeLayerId, type: "select", groupId: "popup", picker: "layer", options: layers.map(({ id, name }) => ({ id, name })) }}
menus={{ layers }} onPropertyChange={(_, value) => { setActiveLayerId(value); onEvent?.(`layer:${value}`) }} />
<SWPopupPropertyRow property={{ id: "color", name: "색상", value: color, type: "color", groupId: "popup" }}
menus={{ colors }} onPropertyChange={(_, value) => { setColor(value); onEvent?.(`color:${value}`) }} />
</SWPopupSection>
</SWPopup>
</SWTheme>
@@ -0,0 +1,214 @@
// 2026-10-20 SW Footer · Popup · Viewer refinement: Footer Story cleanup, Header field dropdowns inside SWPopup,
// SWSlider appearances (panel / property), Popup B, viewer title art at narrow widths.
// Usage: build Storybook first, then node packages/sw/tests/footer-popup-viewer-refinement.browser.mjs [evidence-dir]
import { createRequire } from 'node:module'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const { chromium } = require('playwright')
const out = path.resolve(process.argv[2] ?? 'docs/sw/footer-popup-viewer-refinement-qa'); await mkdir(out, { recursive: true })
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const report = {}
const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : JSON.stringify(d)) }
const index = JSON.parse(await readFile(path.join(root, 'index.json'), 'utf8'))
let errors = []
const page = await browser.newPage({ viewport: { width: 1920, height: 1100 } })
page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
const goto = async id => { errors = []; await page.goto(`${base}/iframe.html?id=${id}&viewMode=story`); await page.locator('#storybook-root > *').first().waitFor(); await page.waitForTimeout(300) }
const log = () => page.locator('[data-callback-log]').first().textContent()
// 1. Footer Stories
const footer = Object.values(index.entries).filter(e => e.type === 'story' && e.title === 'SW/Footer').map(e => e.id)
check('Footer: 기본 Footer / States removed, full-footer + figma-variants kept', !footer.includes('sw-footer--default') && !footer.includes('sw-footer--states') && footer.includes('sw-footer--full-footer') && footer.includes('sw-footer--figma-variants'), footer)
await goto('sw-footer--full-footer')
const sections = await page.locator('h2').allTextContents()
// 2026-10-20 (second pass): 전체 Footer — Figma 기본 구성, 상태, 참고: 실제 화면 were removed; the live composition stays.
check('Footer: full-footer keeps only 실제 Footer 조합 (3 sections removed)', sections.some(s => s.startsWith('실제 Footer 조합')) && !['전체 Footer — Figma', '상태 —', '참고: 실제 화면'].some(t => sections.some(s => s.startsWith(t))) && errors.length === 0, { sections, errors })
check('Footer: one live footer, no Figma / reference image', await page.locator('.hm-sw-footer').count() === 1 && await page.locator('img[src*="composition-2026"], img[src*="narrow-2026"], [aria-label^="Figma 26:10097"]').count() === 0, await page.locator('.hm-sw-footer').count())
// command area does not move when the category changes (workspace footer)
const work = page.locator('.sw-footer-story-workspace .hm-sw-footer')
const slotBox = () => work.locator('.hm-sw-footer__slots').evaluate(e => { const r = e.getBoundingClientRect(); return [Math.round(r.x), Math.round(r.width), e.querySelectorAll('.hm-sw-footer-slot').length] })
const before = await slotBox()
await work.locator('.hm-sw-footer-menu__tab').nth(1).click(); await page.waitForTimeout(150)
check('Footer: category change keeps the command slot position', JSON.stringify(await slotBox()) === JSON.stringify(before), { before, after: await slotBox() })
// Hover · gradient (2026-10-20): side buttons light up whole (both lines + orange ring), + buttons, Model / Layout tabs.
const ORANGE = 'linear-gradient(rgb(218, 104, 19) 8.52%, rgb(255, 187, 137) 37.93%, rgb(229, 105, 13) 71.85%, rgb(174, 54, 7) 92.77%)'
const hoverRead = async (sel, fn) => { const el = work.locator(sel).first(); await el.hover(); await page.waitForTimeout(120); const r = await el.evaluate(fn); await page.mouse.move(2, 2); await page.waitForTimeout(80); return r }
for (const side of ['start', 'end']) {
const sel = side === 'end' ? '.hm-sw-footer-side[data-side="end"]' : '.hm-sw-footer-side:not([data-side="end"])'
const r = await hoverRead(sel, e => ({ title: getComputedStyle(e.querySelector('.hm-sw-footer-side__title')).color, sub: getComputedStyle(e.querySelector('.hm-sw-footer-side__subtitle')).color, ring: getComputedStyle(e, '::after').opacity, w: e.getBoundingClientRect().width, h: e.getBoundingClientRect().height }))
check(`Footer: ${side === 'end' ? '명령어' : '보조기능'} 전체보기 hover = both lines accent + orange ring, 74 × 71`, r.title === 'rgb(255, 92, 0)' && r.sub === 'rgb(255, 92, 0)' && r.ring === '1' && r.w === 74 && r.h === 71, r)
}
const idle = await work.locator('.hm-sw-footer-side[data-side="end"]').evaluate(e => [getComputedStyle(e.querySelector('.hm-sw-footer-side__subtitle')).color, getComputedStyle(e, '::after').opacity])
check('Footer: side button default unchanged (subtitle #F6F4F2, no ring)', idle[0] === 'rgb(246, 244, 242)' && idle[1] === '0', idle)
const menuAdd = await hoverRead('.hm-sw-footer-menu__add', e => ({ bg: getComputedStyle(e).backgroundImage, w: e.getBoundingClientRect().width }))
check('Footer: category + hover = orange + (menu-add-hover, #FF5C00), 28px', /FF5C00/i.test(decodeURIComponent(menuAdd.bg)) && menuAdd.w === 28, menuAdd.bg.slice(0, 80))
const tabsAdd = await hoverRead('.hm-sw-footer-tabs__add', e => ({ bg: getComputedStyle(e, '::before').backgroundImage, w: e.getBoundingClientRect().width }))
check('Footer: Layout + hover = gra-border-orange surface with the +, 24px', tabsAdd.bg.includes(ORANGE) && tabsAdd.bg.startsWith('url(') && tabsAdd.w === 24, tabsAdd.bg.slice(0, 160))
const tabInfo = e => { const t = e.querySelector('.hm-sw-footer-tab__select'), l = e.querySelector('.hm-sw-footer-tab__label') ?? t, c = getComputedStyle(t), r = e.getBoundingClientRect(), lr = l.getBoundingClientRect(); return { bg: getComputedStyle(e).backgroundImage, before: getComputedStyle(e, '::before').backgroundImage, color: c.color, size: c.fontSize, weight: c.fontWeight, shadow: c.textShadow === 'none' ? 'none' : 'outline', text: [Math.round((lr.x - r.x) * 10), Math.round((lr.y - r.y) * 10)].join(','), w: Math.round(r.width), h: Math.round(r.height) } }
const textOf = t => [t.color, t.size, t.weight, t.shadow, t.text, t.w, t.h].join('|')
const l02d = await work.locator('.hm-sw-footer-tab[data-shape="rounded"][data-state="off"]').evaluate(tabInfo)
const l01d = await work.locator('.hm-sw-footer-tab[data-shape="slant"][data-state="off"]').evaluate(tabInfo)
const model = await work.locator('.hm-sw-footer-tab--model').evaluate(tabInfo)
check('Footer: Model (selected) = gra-border-orange surface, black #161410 text without outline, 73 × 22', model.before === ORANGE && model.color === 'rgb(22, 20, 16)' && model.shadow === 'none' && model.w === 73 && model.h === 22, model)
const l02 = await hoverRead('.hm-sw-footer-tab[data-shape="rounded"][data-state="off"]', tabInfo)
const l01 = await hoverRead('.hm-sw-footer-tab[data-shape="slant"][data-state="off"]', tabInfo)
check('Footer: Layout 02 hover = Model surface; text identical to Default (black, size, weight, place, box)', l02.bg === ORANGE && textOf(l02) === textOf(l02d) && l02.color === 'rgb(22, 20, 16)' && l02.weight === model.weight && l02.size === model.size && l02.w === 85, { l02, l02d })
check('Footer: Layout 01 hover = Model surface (slanted); text identical to Default', l01.before === ORANGE && textOf(l01) === textOf(l01d) && l01.color === 'rgb(22, 20, 16)' && l01.weight === model.weight && l01.size === model.size && l01.w === 97, { l01, l01d })
const l01idle = await work.locator('.hm-sw-footer-tab[data-shape="slant"][data-state="off"]').evaluate(tabInfo)
check('Footer: Layout tabs return to their default look after hover', l01idle.before !== ORANGE && textOf(l01idle) === textOf(l01d), l01idle)
// Category pocket (2026-10-20): every selected tab shows one continuous pocket surface (no vacant surface under the row).
const pockets = []
for (let i = 0; i < 4; i++) {
await work.locator('.hm-sw-footer-menu__tab').nth(i).click(); await page.mouse.move(2, 2); await page.waitForTimeout(120)
pockets.push(await work.locator('.hm-sw-footer-menu').evaluate(m => { const t = m.querySelector('[aria-selected="true"]').getBoundingClientRect(), y = t.y + 4
const under = x => document.elementsFromPoint(x, y).find(e => e.matches('.hm-sw-footer__vacant, .hm-sw-footer__group'))?.className
return [m.querySelector('[aria-selected="true"]').textContent, under(t.x + 3), under(t.x + t.width / 2), under(t.right - 3)] }))
}
check('Footer: category pocket surface is the same across the whole tab for 1st · 2nd · 3rd · 4th tab', pockets.every(p => p[1] === p[2] && p[2] === p[3] && p[1] === 'hm-sw-footer__group'), pockets)
const vacantH = await work.locator('.hm-sw-footer__vacant').evaluate(e => Math.round(e.getBoundingClientRect().height))
check('Footer: vacant surface covers the slot row only (46px)', vacantH === 46, vacantH)
await work.locator('.hm-sw-footer-menu__tab').nth(0).click(); await page.mouse.move(2, 2)
await work.locator('.hm-sw-footer-tab[data-shape="rounded"] .hm-sw-footer-tab__select').click(); await page.waitForTimeout(120)
const l02on = await work.locator('.hm-sw-footer-tab[data-shape="rounded"]').evaluate(e => [e.dataset.state, getComputedStyle(e).backgroundImage])
check('Footer: a selected Layout keeps its own (brown) selected surface, distinct from hover', l02on[0] === 'on' && l02on[1] !== ORANGE && l02on[1].includes('linear-gradient'), l02on)
await page.screenshot({ path: path.join(out, 'footer-full.png'), fullPage: true })
await goto('sw-footer--figma-variants')
check('Footer: aux Figma comparison lives in figma-variants (6 rows)', await page.locator('.sw-footer-story-aux-row').count() === 6 && errors.length === 0, errors)
await goto('sw-footer--responsive')
await page.setViewportSize({ width: 1920, height: 1100 })
const panelOrder = await page.locator('.sw-footer-story-width').nth(4).evaluate(async w => {
const toggle = [...w.querySelectorAll('button')].find(b => b.getAttribute('aria-expanded') !== null && b.closest('.hm-sw-footer') && !b.closest('.hm-sw-footer-command'))
return toggle ? toggle.getAttribute('aria-label') : null
})
check('Footer: responsive 860px row renders with an overflow ▲ control', panelOrder !== null && errors.length === 0, { panelOrder, errors })
const respSections = await page.locator('h2').allTextContents()
check('Footer: 화면 폭 변화 keeps the 6 widths, 참고 좁은 화면 + Figma 기본 구성 removed', await page.locator('.sw-footer-story-width').count() === 6 && !respSections.some(t => t.startsWith('참고: 실제 좁은 화면') || t.startsWith('Figma 기본 구성')) && await page.locator('img[src*="narrow-2026"]').count() === 0, respSections)
// 2. Popup layer / color = the property window pickers (SWPopupPropertyRow); SWHeader field dropdowns stay header-only.
await goto('sw-header--overview')
const headerField = await page.locator('.hm-sw-header .hm-sw-header__field--layer').first().evaluate(e => { const c = getComputedStyle(e), v = getComputedStyle(e.querySelector('.hm-sw-header__field-value')); return [c.color, c.height, v.color, v.fontWeight].join('|') })
check('Header: layer field keeps the header design (#F6F4F2, 38px, value 700)', headerField === 'rgb(246, 244, 242)|38px|rgb(246, 244, 242)|700', headerField)
await goto('sw-popup--custom-content')
const rowSig = loc => loc.evaluate(row => { const s = e => { const c = getComputedStyle(e); return [c.color, c.backgroundColor, c.backgroundImage.slice(0, 30), c.borderTopColor, c.height, c.fontSize, c.fontWeight, c.textShadow].join(',') }; return ['.hm-sw-property__value', '.hm-sw-property__trigger, .hm-sw-header__input', 'label'].map(q => row.querySelector(q) ? s(row.querySelector(q)) : '-').join(' / ') + ' / ' + row.querySelector('.hm-sw-property__value')?.dataset.picker })
const panelRows = page.locator('[data-popups] .hm-sw-property').nth(2)
const settingsPopup = page.locator('[data-popup="settings"]')
for (const id of ['layer', 'color']) {
const a = await rowSig(settingsPopup.locator(`[data-property-id="${id}"]`)), b = await rowSig(panelRows.locator(`[data-property-id="${id}"]`))
check(`Popup: 도면 설정 ${id} = 속성창 ${id} picker (value, trigger, label computed look)`, a === b && a.endsWith(id), { a, b })
}
check('Popup: no SWHeader field dropdown inside any popup', await page.locator('.hm-sw-property .hm-sw-header__field').count() === 0)
await settingsPopup.locator('[data-property-id="layer"] .hm-sw-property__trigger').click(); await page.waitForTimeout(120)
const drop = await page.locator('.hm-sw-property__drop[data-picker="layer"]').evaluate(e => getComputedStyle(e).position)
await page.locator('.hm-sw-property__drop[data-picker="layer"] .hm-sw-header__layer-item', { hasText: 'HATCH' }).click(); await page.waitForTimeout(100)
check('Popup: layer picker opens (fixed overlay), selects and closes', drop === 'fixed' && (await log()).includes('layer=hatch') && await page.locator('.hm-sw-property__drop').count() === 0 && (await settingsPopup.locator('[data-property-id="layer"] .hm-sw-property__text').textContent()) === 'HATCH', await log())
await settingsPopup.locator('[data-property-id="color"] .hm-sw-property__swatch-area').click(); await page.waitForTimeout(120)
await page.locator('.hm-sw-property__drop[data-picker="color"]').getByLabel('파랑').click(); await page.waitForTimeout(100)
check('Popup: color picker chip commits the RGB value', (await log()).includes('color=0,0,255'), await log())
if (await page.locator('.hm-sw-property__drop').count()) await page.keyboard.press('Escape')
const frames = await page.locator('[data-popups] .hm-sw-property').count()
check('Popup: frame / title / close unchanged (3 popups in the main section)', frames === 3)
// 3. SWSlider appearances
const sliders = await page.locator('[data-sw-slider]').evaluateAll(list => list.map(e => ({ look: e.dataset.appearance, h: Math.round(e.getBoundingClientRect().height), fill: e.style.getPropertyValue('--hm-sw-range-fill'), paint: getComputedStyle(e).getPropertyValue('--hm-sw-range-track-paint').trim().length > 0, range: getComputedStyle(e).getPropertyValue('--hm-sw-slider-panel-range').trim() })))
check('Slider: A = panel (18px, no property paint), B = property (16px, property paint), same fill', sliders[0].look === 'panel' && sliders[0].h === 18 && !sliders[0].paint && sliders[1].look === 'property' && sliders[1].h === 16 && sliders[1].paint && sliders[0].fill === sliders[1].fill && sliders[0].range === '#266651', sliders)
await page.getByLabel('투명도 B').focus(); await page.keyboard.press('ArrowRight'); await page.waitForTimeout(100)
const afterB = await page.locator('[data-sw-slider]').evaluateAll(list => list.map(e => e.value))
check('Slider: one shared value; keyboard change on B commits through onValueCommit', /opacity\(property\)=50/.test(await log()) && afterB.every(v => v === '50'), { log: await log(), afterB })
await page.getByRole('combobox', { name: '게이지바 스타일' }).selectOption('property'); await page.waitForTimeout(100)
const chosen = await page.getByLabel('투명도 (선택한 스타일)').evaluate(e => e.dataset.appearance)
check('Slider: appearance selection switches the look only', chosen === 'property' && (await log()).includes('appearance=property'), chosen)
const disabledSlider = await page.getByLabel('투명도 (비활성)').evaluate(e => ({ d: e.disabled, o: getComputedStyle(e).opacity }))
check('Slider: disabled 40%', disabledSlider.d && disabledSlider.o === '0.4', disabledSlider)
await page.locator('[data-slider-popups]').screenshot({ path: path.join(out, 'popup-slider-styles.png') })
await page.locator('[data-popups]').screenshot({ path: path.join(out, 'popup-custom-dropdowns.png') })
// The property window gauge itself is unchanged (property-panel-compare)
await goto('sw-header--property-panel-compare')
const prop = await page.locator('.hm-sw-property__value > input[type="range"]').first().evaluate(e => { const r = e.getBoundingClientRect(); return [Math.round(r.width), Math.round(r.height), getComputedStyle(e).getPropertyValue('--hm-sw-range-track-paint').trim().length > 0] })
check('Slider: property window gauge keeps 86 × 16 and its paint', prop[0] === 86 && prop[1] === 16 && prop[2], prop)
// 3b. Unit select: Popup 도면 설정 = Viewer 축척 지정 (same SWSelect, same computed look in every state)
const selProps = ['appearance', 'height', 'paddingLeft', 'paddingRight', 'borderTopWidth', 'borderTopColor', 'borderTopStyle', 'borderRadius', 'backgroundColor', 'color', 'fontSize', 'fontWeight', 'lineHeight', 'outlineStyle', 'outlineColor', 'outlineWidth', 'opacity', 'cursor']
const selRead = loc => loc.evaluate((e, p) => { const s = getComputedStyle(e); return p.map(k => `${k}=${s[k]}`).join(';') + `;class=${e.className};tag=${e.tagName}` }, selProps)
const selStates = async loc => { const d = await selRead(loc); await loc.hover(); await page.waitForTimeout(80); const h = await selRead(loc); await loc.focus(); await page.keyboard.press('Shift+Tab'); await page.keyboard.press('Tab'); await page.waitForTimeout(80); const f = await selRead(loc); await page.mouse.move(2, 2); return { d, h, f } }
await goto('sw-popup--custom-content')
const popupSel = page.locator('[data-popup="settings"] [data-sw-select="unit"]')
const ps = await selStates(popupSel)
const popupOptions = await popupSel.evaluate(e => [...e.options].map(o => o.value))
await popupSel.selectOption('m'); await page.waitForTimeout(80)
const popupPicked = [(await log()).includes('unit=m'), await popupSel.inputValue()]
await goto('sw-viewer--content-variants')
const viewerSel = page.locator('.hm-sw-viewer-panel [data-sw-select="unit"]')
const vs = await selStates(viewerSel)
const viewerOptions = await viewerSel.evaluate(e => [...e.options].map(o => o.value))
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 })
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') })
// 3c. Header Open hysteresis (fix A): grow 520 → 1080, Open returns once and stays; shrink: Open moves first.
await goto('sw-header--document-open')
const probe = page.locator('[data-responsive-open] > [data-width="520"] .sw-story-frame')
const walk = async widths => { const t = []; for (const w of widths) { await probe.evaluate((e, w) => { e.style.width = `${w}px` }, w); await page.waitForTimeout(150); t.push([w, await probe.locator('[data-document-open]').count(), await probe.evaluate(e => e.querySelector('.hm-sw-header__object-area').dataset.squeezed ?? '')]) } return t }
const grow = await walk([520, 560, 600, 640, 680, 720, 760, 820, 900, 1080])
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)
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)
const shrink = await walk([1800, 1440, 1080, 900, 640, 520])
check('Header: shrinking, Open moves into More first (1800) and stays there', shrink[0][1] === 0 && shrink.every(s => s[1] === 0), shrink)
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
check('Header: regrowing after a shrink, Open returns once and stays', regrow.at(-1)[1] === 1 && reflips <= 1 && regrow.at(-1)[2] === '', regrow)
report.header = { grow, wide, shrink, regrow }
// 4. Popup B
await goto('sw-popup--multiple-popups')
const frame = id => page.locator(`[data-popup="${id}"]`).evaluate(e => { const s = getComputedStyle(e), t = getComputedStyle(e.querySelector('.hm-sw-property__title')); return [Math.round(e.getBoundingClientRect().width), s.backgroundColor, s.borderTopColor, t.color, t.fontWeight].join('|') })
check('Popup B: same frame as Popup A', await frame('a') === await frame('b'), [await frame('a'), await frame('b')])
check('Popup B: popup layer picker (no SWHeader field)', await page.locator('[data-popup="b"] .hm-sw-header__field').count() === 0 && await page.locator('[data-popup="b"] [data-property-id="layer"] [data-picker="layer"]').count() === 1)
await page.locator('[data-popup="b"] [data-property-id="layer"] .hm-sw-property__trigger').click()
await page.locator('.hm-sw-property__drop[data-picker="layer"] .hm-sw-header__layer-item', { hasText: 'DIM' }).click()
check('Popup B: layer selection callback and field value', (await log()).includes('b.layer=dim') && (await page.locator('[data-popup="b"] [data-property-id="layer"] .hm-sw-property__text').textContent()) === 'DIM', await log())
await page.setViewportSize({ width: 375, height: 812 })
await page.waitForTimeout(200)
const narrow = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: document.documentElement.clientWidth, b: (r => [Math.round(r.left), Math.round(r.right)])(document.querySelector('[data-popup="b"]').getBoundingClientRect()) }))
check('Popup B: 375px, no page overflow and the popup inside the viewport', narrow.sw <= narrow.vw && narrow.b[0] >= 0 && narrow.b[1] <= narrow.vw, narrow)
await page.screenshot({ path: path.join(out, 'popup-stack-375.png'), fullPage: true })
await page.setViewportSize({ width: 1920, height: 1100 })
// 5. Viewer title art
const art = () => page.locator('.hm-sw-viewer-panel').evaluateAll(list => list.map(p => { const r = p.getBoundingClientRect(), a = p.querySelector('.hm-sw-property__title-art').getBoundingClientRect(), t = p.querySelector('.hm-sw-property__titlebar').getBoundingClientRect(); const before = parseFloat(getComputedStyle(p.querySelector('.hm-sw-property__titlebar'), '::before').width) || 0; return { w: Math.round(r.width), h: Math.round(r.height), artW: Math.round(a.width), gap: Math.max(0, (a.left + 20) - (t.left + before)) } }))
await goto('sw-viewer--variable-size')
const v = await art()
check('Viewer: 250 × auto title art keeps 308px and paints from the left edge (no gap)', v[0].w === 250 && v[0].artW === 308 && v[0].gap < 0.5, v[0])
check('Viewer: 320 / 480 popups unchanged (no gap)', v.slice(1).every(p => p.artW === 308 && p.gap < 0.5) && v[1].w === 320 && v[2].w === 480, v)
await page.locator('.hm-sw-viewer-panel').first().screenshot({ path: path.join(out, 'viewer-250-auto.png') })
// collapse + expand, drag
const first = page.locator('.hm-sw-viewer-panel').first()
const h0 = (await first.boundingBox()).height
const collapse = first.locator('button[aria-expanded]').first()
await collapse.click(); await page.waitForTimeout(100)
const h1 = (await first.boundingBox()).height
await collapse.click(); await page.waitForTimeout(100)
check('Viewer: 250 × auto collapses to the title bar and expands back to auto height', h1 < 40 && Math.abs((await first.boundingBox()).height - h0) < 1, { h0, h1 })
const box = await first.locator('.hm-sw-property__titlebar').boundingBox()
await page.mouse.move(box.x + 60, box.y + 15); await page.mouse.down(); await page.mouse.move(box.x + 120, box.y + 45, { steps: 5 }); await page.mouse.up()
const moved = await first.locator('.hm-sw-property__titlebar').boundingBox()
check('Viewer: 250 × auto drags by its title bar', Math.round(moved.x - box.x) === 60 && Math.round(moved.y - box.y) === 30, { dx: moved.x - box.x, dy: moved.y - box.y })
await goto('sw-viewer--content-variants')
const cv = await art()
check('Viewer: content variants (incl. 300px) title art has no gap', cv.every(p => p.artW === 308 && p.gap < 0.5), cv)
await goto('sw-viewer--settings-panel')
const sl = await page.locator('.hm-sw-viewer-slider').first().evaluate(e => ({ cls: e.className, look: e.dataset.appearance, h: Math.round(e.getBoundingClientRect().height), range: getComputedStyle(e).getPropertyValue('--hm-sw-slider-panel-range').trim(), track: getComputedStyle(e).getPropertyValue('--hm-sw-slider-panel-track').trim() }))
check('Viewer: SWViewerSlider = SWSlider panel with viewer tokens (#266651 / #e4dbc9)', sl.cls.includes('hm-sw-slider') && sl.look === 'panel' && sl.h === 18 && sl.range === '#266651' && sl.track === '#e4dbc9', sl)
await writeFile(path.join(out, 'result.json'), JSON.stringify({ date: new Date().toISOString(), checks, report }, null, 1))
console.log(`${checks.filter(c => c.ok).length}/${checks.length} PASS`)
await browser.close(); server.close()
process.exit(checks.every(c => c.ok) ? 0 : 1)
@@ -103,11 +103,15 @@ for (const mode of ["sw-first", "ui-first"]) {
const name = e.getByLabel("이름")
await name.click(); await name.fill("평면도-02"); await name.press("Enter")
check(`${mode} E: row input commits`, await last() === "name:평면도-02", await last())
await e.locator(".hm-sw-header__field--layer").click()
const menu = e.locator(".hm-sw-header__layer-menu")
check(`${mode} E: layer dropdown inside the popup is a fixed overlay`, await menu.evaluate(el => getComputedStyle(el).position) === "fixed")
// 2026-10-20: popup layer = the property window picker (SWPopupPropertyRow); no SWHeader field dropdown in a popup.
check(`${mode} E: no SWHeader field dropdown inside the popup`, await e.locator(".hm-sw-header__field").count() === 0)
await e.locator('[data-property-id="layer"] .hm-sw-property__trigger').click()
const menu = page.locator('.hm-sw-property__drop[data-picker="layer"]')
check(`${mode} E: layer picker inside the popup is a fixed overlay`, await menu.evaluate(el => getComputedStyle(el).position) === "fixed")
await menu.locator(".hm-sw-header__layer-item", { hasText: "DIM" }).click()
check(`${mode} E: layer selection callback`, await last() === "layer:dim", await last())
await e.getByLabel("단위").selectOption("cm")
check(`${mode} E: SWSelect unit callback`, await last() === "unit:cm", await last())
// HM UI inside an SW popup.
const ui = section("ui")
@@ -9,7 +9,9 @@ import http from 'node:http'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const out = path.resolve(process.argv[2] ?? 'docs/sw/integrated-improvement-qa/verify')
const sections = process.argv.slice(3).length ? process.argv.slice(3) : ['options', 'tool', 'popup']
// 'tool' opens sw-tool--tool-buttons, retired 2026-10-19 (storybook-inventory retiredToolIds): historical, run it only
// by name against an old build. The default runs the sections whose Stories exist (2026-10-20).
const sections = process.argv.slice(3).length ? process.argv.slice(3) : ['options', 'popup']
await mkdir(out, { recursive: true })
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(root, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
+18 -15
View File
@@ -18,31 +18,34 @@ export async function popupChecks({ page, goto, check, log, rect, out, report })
check('popup content: SWPopupRow is the property row (72px label, 32px row, 28px box)', rowStyle.label === '72px' && rowStyle.minH === '32px' && rowStyle.box === '28px', rowStyle)
await settings.locator('[data-ui-switch]').click()
check('popup content: HM UI Switch works inside', (await log()).includes('snap=false'))
await settings.locator('[data-ui-select]').click()
await page.getByRole('option', { name: 'cm' }).click()
check('popup content: HM UI Select (portal) works inside', (await log()).includes('unit=cm'))
// 2026-10-20: the unit select is SWSelect (the Viewer 축척 지정 select), no longer the HM UI Select.
await settings.locator('[data-sw-select="unit"]').selectOption('cm')
check('popup content: SWSelect (unit) works inside', (await log()).includes('unit=cm') && await settings.getByLabel('단위').inputValue() === 'cm')
await settings.locator('[data-ui-slider] [role="slider"]').focus()
await page.keyboard.press('ArrowRight')
await page.waitForTimeout(100)
check('popup content: HM UI Slider works inside', /opacity=70/.test(await log()), await log())
await settings.locator('[data-ui-apply]').click()
check('popup content: HM UI Button works inside', (await log()).includes('apply'))
const layerField = settings.locator('[data-popup-layer] button.hm-sw-header__field')
// 2026-10-20: popup layer / color are the property window pickers (SWPopupPropertyRow); SWHeader field dropdowns are
// header-only. The same clipping / overlay / callback / Escape requirements apply to the popup pickers.
check('popup dropdown: no SWHeader field dropdown inside the popups', await page.locator('[data-popups] .hm-sw-header__field').count() === 0)
const layerField = settings.locator('[data-property-id="layer"] .hm-sw-property__trigger')
await layerField.click()
const fieldBox = await rect(layerField), menu = settings.locator('.hm-sw-header__layer-menu')
const fieldBox = await rect(settings.locator('[data-property-id="layer"] .hm-sw-property__value')), menu = page.locator('.hm-sw-property__drop[data-picker="layer"]')
const menuBox = await rect(menu)
const hit = await menu.locator('.hm-sw-header__layer-row').last().evaluate(row => { const r = row.getBoundingClientRect(); const e = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); return row.contains(e) })
check('popup dropdown: SW layer dropdown opens right under its field (fixed overlay) and is not clipped', await menu.evaluate(e => getComputedStyle(e).position) === 'fixed' && Math.abs(menuBox.y - (fieldBox.y + fieldBox.h)) < 1 && Math.abs(menuBox.x - fieldBox.x) < 1 && hit, { fieldBox, menuBox, hit })
check('popup dropdown: popup layer picker opens right under its field (fixed overlay, right edge) and is not clipped', await menu.evaluate(e => getComputedStyle(e).position) === 'fixed' && Math.abs(menuBox.y - (fieldBox.y + fieldBox.h)) < 1 && Math.abs((menuBox.x + menuBox.w) - (fieldBox.x + fieldBox.w)) < 1 && hit, { fieldBox, menuBox, hit })
await page.locator('[data-popups]').screenshot({ path: path.join(out, 'popup-custom-dropdown.png') })
await menu.locator('.hm-sw-header__layer-item', { hasText: 'DIM' }).click()
check('popup dropdown: selection callback, list closes, popup stays', (await log()).includes('layer=dim') && await settings.locator('.hm-sw-header__layer-menu').count() === 0 && await settings.isVisible())
await settings.locator('[data-popup-color] button.hm-sw-header__field').click()
const colorPanel = settings.locator('.hm-sw-header__menu-panel')
check('popup dropdown: SW color dropdown becomes a fixed overlay', await colorPanel.evaluate(e => getComputedStyle(e).position) === 'fixed')
check('popup dropdown: selection callback, list closes, popup stays', (await log()).includes('layer=dim') && await menu.count() === 0 && await settings.isVisible())
await settings.locator('[data-property-id="color"] .hm-sw-property__swatch-area').click()
const colorPanel = page.locator('.hm-sw-property__drop[data-picker="color"]')
check('popup dropdown: popup color picker becomes a fixed overlay', await colorPanel.evaluate(e => getComputedStyle(e).position) === 'fixed')
await colorPanel.getByLabel('초록').click()
check('popup dropdown: color chip callback', (await log()).includes('color=green'))
await page.keyboard.press('Escape')
check('popup dropdown: Escape closes the dropdown only', await settings.locator('.hm-sw-header__menu-panel').count() === 0 && await settings.isVisible())
check('popup dropdown: color chip callback', (await log()).includes('color=0,255,0'), await log())
if (await colorPanel.count()) { await colorPanel.getByLabel('초록').focus(); await page.keyboard.press('Escape') }
check('popup dropdown: Escape closes the dropdown only', await colorPanel.count() === 0 && await settings.isVisible())
const object = page.locator('[data-popup="object"]')
check('object popup: read-only rows', await object.getByLabel('이름').evaluate(e => e.readOnly) && await object.getByLabel('길이').evaluate(e => e.readOnly))
@@ -62,8 +65,8 @@ export async function popupChecks({ page, goto, check, log, rect, out, report })
await goto('sw-popup--multiple-popups')
const popup = id => page.locator(`[data-popup="${id}"]`)
const b = popup('b')
await b.locator('[data-popup-layer] button.hm-sw-header__field').click()
const bMenu = b.locator('.hm-sw-header__layer-menu'), bHit = await bMenu.locator('.hm-sw-header__layer-footer').evaluate(row => { const r = row.getBoundingClientRect(); return row.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) })
await b.locator('[data-property-id="layer"] .hm-sw-property__trigger').click()
const bMenu = page.locator('.hm-sw-property__drop[data-picker="layer"]'), bHit = await bMenu.locator('.hm-sw-header__layer-footer').evaluate(row => { const r = row.getBoundingClientRect(); return row.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) })
const bBox = await rect(b), menuB = await rect(bMenu)
check('short popup: dropdown extends past the popup and stays fully visible (no clipping)', menuB.y + menuB.h > bBox.y + bBox.h && bHit, { bBox, menuB, bHit })
await page.locator('[data-stack]').screenshot({ path: path.join(out, 'popup-short-dropdown.png') })
@@ -26,6 +26,9 @@ export const movedHomeMenuIds = Object.fromEntries(["figma-home-on:figma", "capt
export const retiredHomeMenuCompareIds = ["figma", "capture-active", "capture-outline", "capture-recent", "variants", "recent-files"].map(id => `sw-header--home-menu-${id}`)
// 2026-10-20: Property Panel · 4개 비교 moved to SW/Popup in the sidebar; its ID is unchanged (meta id "sw-header").
export const movedToPopupIds = ["sw-header--property-panel-compare"]
// 2026-10-20 (Footer Story cleanup, user request): 기본 Footer and the States page were removed. Their screens are sections
// of sw-footer--full-footer (Figma 기본 구성, 상태); the aux Figma comparison moved to sw-footer--figma-variants.
export const retiredFooterIds = ["default", "states"].map(id => `sw-footer--${id}`)
export const integratedChangedFiles = [
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
@@ -32,6 +32,8 @@ check(`direct URL render: ${sw.length} SW Stories, no error display / page error
// 2. removed IDs are gone
const removed = ['figma', 'capture-active', 'capture-outline', 'capture-recent', 'variants', 'recent-files'].map(id => `sw-header--home-menu-${id}`)
check('removed Home Menu IDs absent from index', removed.every(id => !index.entries[id]))
// 2026-10-20 Footer cleanup: retired Footer IDs are gone, the two main Footer Stories stay.
check('removed Footer IDs absent, full-footer / figma-variants kept', ['sw-footer--default', 'sw-footer--states'].every(id => !index.entries[id]) && ['sw-footer--full-footer', 'sw-footer--figma-variants'].every(id => index.entries[id]))
// 3. Property panel in Popup: four states
await page.goto(`${base}/iframe.html?id=sw-header--property-panel-compare&viewMode=story`)
await page.locator('[data-compare]').first().waitFor()
@@ -159,7 +159,7 @@ const snapshot = page => page.evaluate(() => [...document.querySelectorAll('.hm-
}))
const regression = []
for (const width of [1920, 1440, 1180, 900, 560]) for (const id of ids) {
if (width !== 1440 && !/responsive|viewer--viewer-header|footer--default|overview/.test(id)) continue
if (width !== 1440 && !/responsive|viewer--viewer-header|footer--full-footer|overview/.test(id)) continue
const page = await newPage(width)
await open(page, id)
const a = await snapshot(page)