Merge branch 'integration/sw-home-menu' into feature/sw-home-menu

Integrate the SW home (logo) menu into SWHeader for SW general mode only.
SW viewer mode code and stories are unchanged.

- Resolve conflicts: keep both sw-home-menu and sw-viewer exports;
  renumber AI docs (catalog viewer 8->9, patterns home menu 7->8,
  design rules home menu 11->12)
- SWHeader: start the home menu after the file name when it sits
  inside controls, so the file name stays visible
- AI guides: mark home menu as general-mode only; forbid it in viewer
  mode (AI_USAGE, catalog, design rules, patterns, VIEWER.md, AI_GUIDE)
- Tests: ignore wildcard identifiers in ai-docs check; update expected
  Storybook SW titles (SW/Tool removed, SW/Viewer added)
- Add merge QA report docs/sw/sw-home-menu-merge-2026-10-19.md
This commit is contained in:
shkim4 committed 2026-10-19 18:21:19 +09:00
commit 3a5e29f2fa
122 files changed
+6547 -41

No files matched your search

+2 -2
View File
@@ -212,7 +212,7 @@ import { Button, Card, CardContent, FilterBar, FilterBarActions, FilterBarSearch
### 예시 B1 — SW 일반 모드 (편집 Header + 도구 막대 + 설정 Popup)
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(일반 모드) → COMPOSITION_PATTERNS "6. 파일 열기 + 도구 막대", 예제 `ai/examples/ExampleF-ViewerToolbar.tsx`(파일 이름의 Viewer는 과거 명칭이며 일반 모드 `SWHeader` 예제)
- 재사용: `SWHeader`(`documentOpenPlacement="header"`, `controls`, `tools`), `SWToolGroup` + `SWToolButton`(Model/Layout 탭, Fit 등), `SWPopup` + `SWPopupSection` + `SWPopupRow` + `SWHeaderInput`(설정 Popup)
- 재사용: `SWHeader`(`documentOpenPlacement="header"`, `controls`, `tools`), `SWToolGroup` + `SWToolButton`(Model/Layout 탭, Fit 등), `SWPopup` + `SWPopupSection` + `SWPopupRow` + `SWHeaderInput`(설정 Popup), 로고(홈) 버튼 메뉴는 `SWHeader`의 `homeMenu` + `SWRecentFiles`(**일반 모드 전용**, 예제 `ExampleG-HomeMenu.tsx`)
- 신규: SW에 없는 기능(예: 검색) → 소비 프로젝트 컴포넌트, `<SWTheme>` 안, `--hm-sw-header-*` 토큰, 안쪽 입력은 `SWHeaderInput`(예제 `ExampleD-SearchExtension.tsx`)
```tsx
@@ -225,7 +225,7 @@ import "@hm-design/sw/styles.css"
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(뷰어 모드) → `VIEWER.md`, 예제 `ai/examples/ExampleH-ViewerMode.tsx`
- 재사용: `SWViewerHeader`(`groups` 데이터: `button`·`toggle`·`menu`·`tabs`·`switch`·`custom` 항목, `onCommand`, `trailing`에 `SWViewerStatus`·`SWViewerHeaderButton`), Dropdown은 `type: "menu"` 항목의 `entries`(단독·우클릭은 `SWViewerMenu`), 팝업은 `SWViewerPanel` + `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`
- 신규: 뷰어에 없는 패널 콘텐츠 → 소비 프로젝트 컴포넌트를 `SWViewerPanel`의 `children`에, 안쪽은 `SWViewerField` + `SWViewerInput` 등, 외형은 `--hm-sw-viewer-*` 토큰
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기, 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기, 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
@@ -0,0 +1,73 @@
# AI 가이드 초안 — SW 로고 클릭 메뉴 (`SWHomeMenu`)
> 상태: **통합 전 초안**입니다(Task B 2단계, 2026-10-19). 통합 승인 후 `packages/sw/ai/COMPONENT_CATALOG.md`·`COMPOSITION_PATTERNS.md`·`DESIGN_RULES.md`로 옮깁니다. 지금은 `@hm-design/sw` 진입점에서 export되지 않으므로 소스 경로 `packages/sw/src/components/sw-home-menu`를 사용합니다.
> 기준: Figma `rj6wpsfk24lOWvWiVRAANP` 26:10591(메뉴), 26:11355…26:11631(타일 15개), 26:12604…(섹션 제목), 662:67267·662:67331·662:67471(최근파일 하위 패널). 구현: `SWHomeMenu.tsx`, `SWRecentFiles.tsx`, `theme/sw-home-menu.css`.
## 1. 무엇인가
- SW Header의 **로고(홈) 버튼을 누르면 Header 위에 겹쳐 나타나는 2단 메뉴**입니다. 기본은 숨김이고, 로고를 다시 누르면 닫힙니다.
- 구성: 섹션(제목 badge + 타일 그룹) 여러 개 → 섹션마다 80 × 46 타일 여러 개. 타일 하나가 하위 패널(예: 최근파일 목록)을 열 수 있습니다.
- 운영체제 파일 선택창이 아닙니다. "찾아열기" 같은 항목의 실제 동작(파일 대화상자 등)은 host가 `onSelect`에서 수행합니다.
## 2. 최소 사용법
```tsx
import { SWHeader, SWTheme } from "@hm-design/sw"
import { SWHomeMenu, type SWHomeMenuSectionData } from "@hm-design/sw" // 통합 후 경로
const sections: SWHomeMenuSectionData[] = [
{ id: "file", title: "작업중인 파일", items: [
{ id: "save", label: "저장", icon: <SaveIcon />, toggle: false, onSelect: () => host.save() },
{ id: "export", label: "내보내기", icon: <ExportIcon /> },
] },
]
function App() {
const [open, setOpen] = useState(false)
return (
<div style={{ position: "relative" }}>
<SWHeader onHomeClick={() => setOpen(v => !v)} /* 기존 props */ />
{/* 통합 전: host가 위치를 지정합니다(Header 객체 수정 영역 왼쪽 - 2px, 창 영역 왼쪽까지). */}
<div style={{ position: "absolute", top: 0, left: objectAreaLeft - 2, maxWidth: windowLeft - objectAreaLeft + 2, zIndex: 3 }}>
<SWTheme><SWHomeMenu sections={sections} open={open} onOpenChange={setOpen} /></SWTheme>
</div>
</div>
)
}
```
## 3. 데이터 규칙 (새 메뉴를 만들 때)
| 하고 싶은 것 | 방법 |
|---|---|
| 항목 추가 | 섹션 `items`에 `{ id, label, icon }`을 추가합니다. 폭·구분선·모서리·섹션 폭은 자동입니다 |
| 섹션 추가 | `{ id, title, items }`를 추가합니다. 제목 badge 폭은 글자 + 46px로 자동입니다 |
| 숨김 | `hidden: true`. 자리를 남기지 않습니다 |
| 비활성 | `disabled: true`. 기존 SW 규칙대로 opacity .4이며 Hover가 없습니다 |
| 실행만 하는 버튼 | `toggle: false`. Active(Variant3)가 되지 않습니다 |
| 켜고 끄는 버튼 | 기본값(`toggle` 생략)입니다. 클릭하면 Active, 다시 클릭하면 Default입니다 |
| Active 제어 | `activeIds` + `onActiveChange`(controlled) 또는 `defaultActiveIds`. 하나만 켜려면 `selection="single"` |
| 하위 패널 | `panel: <SWRecentFiles files={…} onFileOpen={…} />`. 패널은 한 번에 하나만 열리고 열린 동안 하단 강조가 전체 외곽선이 됩니다 |
| 아이콘 | `currentColor` SVG 하나를 권장합니다(Default `#B6B4AF`, Hover·Active `#FF5C00` 자동). 색이 고정된 이미지 쌍이면 `hoverIcon`을 함께 줍니다 |
| 이벤트 | 항목 `onSelect(event)` → 메뉴 `onItemSelect(event)`. `event.active`는 클릭 후 상태입니다 |
## 4. 상태와 닫기 (Figma 기준 + 접근성 보완)
| 동작 | 결과 | 근거 |
|---|---|---|
| 로고 클릭 | 열기 / 다시 클릭하면 닫기 | Figma btn_home ON_CLICK → OVERLAY, 재클릭 닫기는 사용자 확정 |
| 타일 Hover | Variant2: 주황 gradient 1px 테두리, 아이콘·글자 `#FF5C00`, 1px 위·왼쪽 이동 | Figma Default ON_HOVER → V2 |
| 타일 클릭 | Variant3(Active, V2와 같은 모양) → 다시 클릭하면 Default | Figma V2 → V3 → Default |
| 메뉴 빈 곳 클릭 | 닫기 | Figma Group ON_CLICK → CLOSE (`closeOnBackgroundClick`) |
| Escape | 열린 패널을 먼저 닫고, 다시 누르면 메뉴를 닫은 뒤 로고로 Focus 복귀 | Figma 없음 · 접근성 (`closeOnEscape`) |
| 메뉴 밖 클릭 | 기본은 닫지 않음 | Figma overlay에 바깥 동작 없음 (`closeOnOutsideClick`) |
| ←/→/Home/End | 타일 사이 이동. 열린 패널 타일에서 ↓를 누르면 패널로 이동, ↑/↓로 행 이동, Enter로 파일 열기 | Figma 없음 · 접근성 |
| 최근파일 행 선택 | `onFileOpen(file)` 후 메뉴 닫기(`closeOnOpen`) | Figma 없음 · 검토 대상 |
## 5. 하지 말 것
- 타일·섹션·제목을 Figma 화면별로 따로 만들지 않습니다. 모든 메뉴는 `sections` 데이터 하나로 표현합니다.
- `hm-sw-home-menu*` class를 소비 코드에서 붙여 외형을 만들지 않습니다(`className`은 확장용입니다).
- 새 색·크기를 임의로 정하지 않습니다. 필요한 값은 `--hm-sw-home-menu-*`·`--hm-sw-recent-files-*` 토큰을 `style`로 덮어씁니다.
- Font Weight는 400/500/700만 씁니다(타일 라벨·파일명 700, 섹션 제목 400).
- 홈 버튼이나 창 영역을 메뉴 안에 다시 그리지 않습니다. 그것은 `SWHeader`가 담당합니다.
+259
View File
@@ -0,0 +1,259 @@
# 작업 B 1단계 — Figma 추가 디자인 분석·설계 보고 (2026-10-19)
> **2단계 이후 정정(우선 적용):** [2단계 보고](./stage2-report.md)를 기준으로 봅니다. 아래 1단계 본문은 당시 기록으로 남겨 둡니다.
> - 로고 클릭 = 2단 Header 메뉴, 기본 숨김, 재클릭 닫힘(사용자 최종 정정).
> - 662:67267은 Figma 주석 "최근파일" 상태 화면입니다. 최근파일 목록 662:67331과 2뎁스 미리보기 662:67471이 함께 붙으며, 두 노드는 `SWRecentFiles`로 구현했습니다.
> - V3 = 클릭으로 켜지고 다시 클릭하면 꺼지는 Active 상태입니다(사용자 지시).
> - 아이콘 prop은 `iconHover`가 아니라 `hoverIcon`입니다(Task A `SWToolButton`과 이름을 맞춤).
> - `accent`의 기본값은 `"auto"`입니다(패널이 열린 동안 outline).
> 상태: **분석·설계 보고만 작성했습니다(코드 수정 없음)**. 구현, Commit, Push는 하지 않았고 2단계는 사용자 승인 후 시작합니다.
> 대상: Figma `rj6wpsfk24lOWvWiVRAANP`, 속성Bar `26:10492`와 로고 클릭 노드 21개. 기준 브랜치는 `claude/sw-figma-logo-menu-36832b`(HEAD `5faa1f1`, `feature/sw-package`와 동일)입니다.
> 수집 자료: [figma-nodes.json](./figma-nodes.json)(노드별 메타데이터·디자인 컨텍스트 정규화·Prototype 연결), [screenshots/](./screenshots/)(노드 22개 PNG, 1x).
## 0. 수집 방법과 한계
| 항목 | 내용 | 상태 |
|---|---|---|
| Figma 접근 | 공식 Figma MCP(`get_metadata`, `get_design_context`, `get_variable_defs`, `get_screenshot`)를 사용했습니다. `figma-design-to-code` 스킬을 먼저 불러왔습니다. 인증이 필요한 `plugin:figma:figma` 서버는 사용하지 않았습니다. | 완료 |
| 세부 속성 | 읽기 전용 `use_figma`(Plugin API)로 reactions, gradient stop, stroke, effect, font, 변형 간 diff를 확인했습니다. 캔버스는 수정하지 않았습니다. | 완료 |
| 디자인 컨텍스트 저장 | React/Tailwind 원문 대신 구현에 필요한 값만 `figma-nodes.json`에 정규화했습니다. 원문은 같은 node id로 다시 받을 수 있습니다. | 정규화본만 저장 |
| Asset | `get_design_context`의 asset URL은 2026-10-26에 만료됩니다. 이번 단계에서는 asset을 받지 않았고, 2단계에서 다시 받습니다. | 미수집 |
| Storybook 비교 | `localhost:6024`에서 이미 실행 중인 Storybook의 `SW/Header/Overview` 속성Bar를 측정했습니다. 그 서버가 어느 checkout에서 실행되는지는 확인하지 못했습니다. Pane 폭은 390px, DPR은 1.5였습니다. | 실측 1회, 출처 미확인 |
| 형제 노트 | 공유 노트 폴더에 다른 세션의 노트가 없었습니다. 작업 A의 계획은 사용자 메모리의 확정 기록 기준으로 대조했습니다. | 확인 |
## 1. 노드별 표 (22개)
"로고 클릭 화면 21개"는 실제로 **화면 상태 2개**와 **구성 컴포넌트 19개**(섹션 제목 4개, 버튼 세트 15개 × 상태 3개)입니다. 다섯 번째 섹션 제목 `tit_마이세팅`(26:12628, "My Setting")은 요청 목록에 없지만 화면에서 사용되므로 참고로 함께 정리했습니다.
| # | Node | 이름 / 유형 | 크기 | 역할 | 상태·Prototype |
|---|---|---|---|---|---|
| 1 | 26:10492 | 속성Bar / FRAME (`header_green` → `선택한것` 26:10491 하위) | 1222×28 | 객체 속성 편집 strip | input ON_HOVER → hover(26:11659) |
| 2 | 26:10591 | 헤더/꾸러미 ver2/home on / COMPONENT | 1920×77 | **로고 클릭 overlay 본체** | btn_home ON_CLICK → OVERLAY(TOP_LEFT, 배경 없음), 메뉴 그룹 ON_CLICK → CLOSE |
| 3 | 662:67267 | 같은 이름 / FRAME | 1920×77(렌더 1935×193) | 2의 정적 사본. 팝업가이드 Variant5(K 미리보기)와 다른 하단 강조 | hover만 있음(open/close 없음) |
| 4 | 26:12604 | tit_작업중인파일 | 124×19 | 섹션 제목 "작업중인 파일" | 없음 |
| 5 | 26:12610 | tit_다른파일열기 | 123×19 | "다른파일 열기" | 없음 |
| 6 | 26:12622 | tit_파일손상시 | 111×19 | "파일 손상시" | 없음 |
| 7 | 26:12616 | tit_궁금하면 | 95×19 | "궁금하면" | 없음 |
| 8 | 26:11355 | btn_저장 / COMPONENT_SET | 3×(80×46) | 저장 · save · 그룹 첫 버튼 | D→hover V2, V2 click→V3, V3 click→D |
| 9 | 26:11371 | 복사저장 | 〃 | save_as · 중간 | 〃 |
| 10 | 26:11387 | 내보내기 | 〃 | file_open · 중간 | 〃 |
| 11 | 26:11403 | 도면묶기 | 〃 | inventory_2 · 마지막(구분선 없음) | 〃 |
| 12 | 26:11416 | 찾아열기 | 〃 | folder_open · 첫 버튼. 아이콘 top 5 | 〃. 구분선 gradient가 다름 |
| 13 | 26:11450 | 최근파일 | 〃 | 시계 붙은 파일 아이콘 · 중간 | **V2→V3 클릭 reaction 없음**. 구분선 gradient가 다름 |
| 14 | 26:11485 | 새파일 | 〃 | insert_drive_file · 마지막 | 〃 |
| 15 | 26:11507 | 복구하기 | 〃 | rotate_left · 첫 버튼 | 〃 |
| 16 | 26:11529 | 임시파일 | 〃 | file_copy · 마지막 | 〃 |
| 17 | 26:11542 | 디스플레이 | 〃 | monitor 24×22 · 첫 버튼. 라벨 "Display..."(원문에서 이미 말줄임) | 〃 |
| 18 | 26:11558 | 저장하기 | 〃 | 설정 저장 · 중간. gap 2 | 〃 |
| 19 | 26:11586 | 불러오기 | 〃 | save_alt · 마지막 | 〃 |
| 20 | 26:11599 | 설명보기 | 〃 | content_paste · 첫 버튼 | 〃 |
| 21 | 26:11615 | 문의하기 | 〃 | question_answer · 중간 | 〃 |
| 22 | 26:11631 | 소프트웨어 정보 | 〃 | 라벨 "S/W정보" · 마지막 | **V3가 button이 아니고 Default로 돌아가는 reaction 없음** |
## 2. 속성Bar 차이표 (Figma 26:10492 ↔ 현재 구현·Storybook)
비교 기준은 `packages/sw/src/theme/sw.css`(`.hm-sw-property-bar*`, `.hm-sw-header__input`), `SWHeaderPropertyBar.tsx`, Storybook `SW/Header/Overview` 실측입니다. **실제 차이가 있는 항목만** 적었습니다. 수정 위치는 모두 기존 공유 파일인 `sw.css`이므로 **통합 단계 항목**입니다.
| # | 항목 | Figma | 현재 구현 / Storybook | 영향 | 수정 위치 |
|---|---|---|---|---|---|
| P1 | 테두리 색 | 좌·우·하 1px **세로 gradient**(`--gra-border-green`): `#08251C` 0% → `#208769` 37.1% → `#08241C` 80.5% → `#051612` 100%(위→아래) | 단색 `1px solid #08251C`. Storybook computed도 `rgb(8,37,28)` 단색 | 중간 높이의 초록 하이라이트(`#208769`)가 없음 | **통합 단계** `sw.css` `.hm-sw-property-bar`(gradient border는 기존 hover border 기법 재사용 검토) |
| P2 | 객체명 글자 외곽선 | 검정 1.3px Stroke + `#FFC600` 0.3px. 기존 정책상 검정 외곽선만 적용 | `text-shadow: none`. 외곽선 목록(`sw.css` 약 1472–1477행)에 `.hm-sw-property-bar__object`가 없음 | 노란 글자 가장자리가 Figma보다 흐림 | **통합 단계** `sw.css`, `--hm-sw-header-text-outline` 적용 |
| P3 | 라벨 글자 외곽선 | 14px 라벨에 검정 1px Stroke | `text-shadow: none` | 라벨 대비가 약함 | **통합 단계** `sw.css`, 1px 외곽선(`--hm-sw-header-label-outline`) 적용 검토 |
| P4 | 라벨 박스 폭 | 고정 텍스트 박스(반지름 46, 지름·원주·면적 30), 가운데 정렬. 박스 안 좌 1.4–2.1px, 우 1.8–2.8px 여백 | 자연 폭(예: "원주" 26.9px). 라벨→입력 간격이 Figma보다 약 2–3px 좁음. 콘텐츠 폭은 527.6px로 Figma 538px보다 짧음 | 글꼴 대체(Dotum → Noto Sans KR) 영향과 섞여 있음 | **검토 대상**(§5 R-P1). 고친다면 통합 단계 `sw.css` |
| P5 | 세로 위치 | 내용 Frame top 4px(20px 행) | 실측 top 3.5px(bar 875.2 → field 878.7, 27px 영역 가운데 정렬) | 0.5px | **검토 대상**(§5 R-P2) |
**의도된 차이(수정하지 않음):** 바 폭은 Figma 1222px 고정 대신 콘텐츠 폭입니다(2026-10-19 사용자 확정). 글꼴은 Dotum Regular + Stroke 대신 Noto Sans KR이며 객체명·값은 700입니다(기존 Typography 정책). 입력 폭은 Figma 고정 58/66px 대신 값 길이를 따릅니다. 필드 순서와 객체명 문구는 Story fixture 데이터입니다.
**일치(차이 없음):** 높이 28, 배경 `#271F13`, 아래 radius 6, inset shadow `0 4 4 rgba(0,0,0,.6)`, 객체명 90px·`#FFC600`, 필드 간격 16·라벨-입력 6, 라벨 `#F6F4F2`·자간 4%, 입력 `#E60403`·자간 2%·padding 2/4/2/6·radius 2. 입력 상태 Default(면 없음) / hover(24px, 1px .25) / on(`#0F0D0A`, 1px .3)과 hover reaction도 같습니다. 아이콘은 Figma와 구현 모두 없습니다.
**미검증:** 1920px 폭 실측, Hover·편집 상태 Storybook 실측, P1–P3를 고친 뒤의 렌더 비교.
## 3. 로고 클릭 화면 분석
### 3.1 화면 구성 (26:10591 기준, 1920 디자인)
```
y 0 ─ border-top 2px #5F4B0F ───────────────────────────────────────────────────
[btn_home 80] (기존 Header가 비쳐 보임: 문서 제목 등) [섹션 5개, x377–1678] [창 영역 240: 인쇄·K pill | 창이동 | – | ×]
y 46 ─ border-bottom 2px #5F4B0F ───────────────
y 47 ─ 하단 강조 strip 30px(섹션 뒤, 아래로 보이는 주황 gradient)
y 77 ─ 메뉴 끝(Header 48px보다 29px 아래로 내려옴)
```
- **Overlay**: 로고 클릭 시 `home on` 전체가 기존 Header 위에 TOP_LEFT로 겹칩니다(배경 없음). `bg black`과 `document_tit`가 숨겨져 있어 그 부분은 기존 Header가 그대로 보입니다. 즉 **새로 그려지는 것은 섹션 메뉴(x375–1680)이고**, 홈·창 영역은 기존 Header와 같은 컴포넌트 사본입니다.
- **섹션**(5개): 제목 badge(19px, Union 도형) + 버튼 그룹 box(46px) + 섹션 면(75px). 섹션 폭 = 버튼 수 × 80 + 좌우 padding(10, 첫·마지막 섹션은 한쪽 11)입니다.
- 작업중인 파일: 저장 · 복사저장 · 내보내기 · 도면묶기
- 다른파일 열기: 찾아열기 · 최근파일 · 새파일
- 파일 손상시: 복구하기 · 임시파일
- My Setting: Display... · 저장하기 · 불러오기
- 궁금하면: 설명보기 · 문의하기 · S/W정보
- **버튼**: 80×46, 아이콘 18px(31,6), 라벨 14px(top 25). 마지막 버튼을 뺀 버튼 오른쪽에 1×44 gradient 구분선이 있습니다.
### 3.2 공통 영역과 화면별 차이
| 구분 | 내용 |
|---|---|
| 공통(두 화면 동일) | 섹션 5개·버튼 15개·제목·구분선·면 gradient, 홈 버튼, 창 영역(인쇄·K·창이동·최소·종료), border-top/bottom `#5F4B0F` |
| 26:10591만 | 하단 강조 = 섹션별 사각형 5개(`#FEC501` + `#5F4B0F → #FF5100` gradient), btn_home 클릭 → open, 메뉴 클릭 → close |
| 662:67267만 | 하단 강조 = 메뉴 전체 1305×30에 2px gradient stroke(`#FF5100 → #5F4B0F`, fill 숨김), 팝업가이드 Variant5(K 미리보기 209×186, "다중인쇄/도면관리") 표시, open/close reaction 없음, btn_home 배경 asset이 다름 |
| 버튼 세트 공통 | Default / Variant2 / Variant3. **V2와 V3는 시각 속성이 같습니다**(Plugin API로 하위 노드 전체를 비교했고 위치만 다름) |
| 버튼별 차이 | 아이콘 asset과 위치(찾아열기 top 5, Display 24×22), 라벨 박스 폭(33–70), 그룹 내 위치에 따른 hover radius(첫 3 0 0 3 / 중간 0 / 마지막 0 3 3 0), 구분선 유무(마지막 없음), 찾아열기·최근파일만 구분선 gradient가 다름, 저장하기·S/W정보만 flex gap 2 |
### 3.3 상태
| 상태 | Figma 근거 | 시각 |
|---|---|---|
| Default | 각 세트 Default | 면 없음, 아이콘 회색, 라벨 `#F6F4F2` + 검정 외곽선 |
| Hover | Default ON_HOVER → V2 | 1px INSIDE 주황 세로 gradient 테두리(`#DA6813` 8.5% / `#FFBB89` 37.9% / `#E5690D` 71.9% / `#AE3607` 92.8%), 아이콘·라벨 `#FF5C00`, 아이콘·라벨 1px 위·왼쪽 이동 |
| Pressed / 선택(?) | V2 ON_CLICK → V3, V3 ON_CLICK → Default | V2와 동일. 의미는 미정(§5 R-3) |
| Open / Close | btn_home ON_CLICK → OVERLAY, 메뉴 영역 ON_CLICK → CLOSE | — |
| Focus / Disabled / 키보드 | **Figma에 없음** | §5 검토 대상 |
### 3.4 전환 관계 (Prototype)
```
[기존 Header] --btn_home click--> [home on overlay 26:10591]
[home on] --메뉴 영역(Group 2882) click--> CLOSE → [기존 Header]
버튼: Default --hover--> V2 --click--> V3 --click--> Default (최근파일: V2에서 끝, S/W정보: V3에서 끝)
팝업가이드: Default --hover--> Variant5(K 미리보기) ※ 기존 SWHeaderKGuide와 같은 대상
```
Figma에는 메뉴 밖 클릭, Escape, 로고 재클릭으로 닫기, 각 버튼의 실제 동작(파일 대화상자, 최근 파일 목록 등)이 정의되어 있지 않습니다(§5).
## 4. 공통 컴포넌트 구조·공개 API 초안
### 4.1 재사용 우선순위 판단
| 단계 | 후보 | 판단 |
|---|---|---|
| 기존 SW | `SWHeaderHome`(`onHomeClick`), `SWHeaderKGuide`, 창 제어, `useSWHeaderDropdown`의 닫기 계약(Escape·바깥 클릭·Tab 이탈·Focus 복귀), 텍스트 외곽선·focus 토큰, `SWTheme` | **재사용.** Header 쪽은 그대로 두고 메뉴만 새로 만듭니다. 홈·창 영역을 다시 그리지 않습니다 |
| 작업 A 계획 | `SWToolButton`(pill/text/document), `SWToolGroup`(selection), `SWPopup*` | §4.4 참고. 그대로 쓸 수 있는 appearance가 없습니다 |
| shadcn / Radix | Popover, NavigationMenu | `@hm-design/sw`는 `@hm-design/ui`·Radix에 의존하지 않습니다(독립 패키지). 새 의존성은 **별도 승인 대상**이므로 기존 SW hook 방식을 따릅니다 |
| 최소 Custom | 섹션 badge, 그룹 box, 80×46 타일 버튼 | 시각 요소만 새로 만듭니다(Figma 근거 있음) |
### 4.2 구조
```
<SWHomeMenu> overlay 패널(region/dialog 역할은 §5 R-6), open 상태·닫기 계약
<SWHomeMenuSection title> 섹션 면 + 제목 badge(폭은 글자에 맞춤) + 그룹 box
<SWHomeMenuItem icon label /> 80×46 타일 button. 마지막 항목은 구분선 자동 생략, 첫/마지막 hover radius 자동
```
데이터형(`sections`)과 조합형(`children`) 중 하나만 써도 21개를 모두 표현할 수 있습니다. 위치·첫/마지막 여부·구분선·섹션 폭은 **항목 수에서 자동 계산**합니다. 따라서 AI가 항목만 추가해도 같은 스타일이 적용됩니다.
### 4.3 공개 API 초안
```ts
export interface SWHomeMenuItemData {
id: string
label: string // 한 줄, 넘치면 말줄임 + title (기존 규칙)
icon?: ReactNode // currentColor SVG 권장: Default 회색 → Hover #FF5C00을 CSS가 처리
iconHover?: ReactNode // 색 고정 이미지 쌍일 때만(기존 close/close-hover 방식)
disabled?: boolean // 시각은 검토 대상(R-4)
hidden?: boolean // 미렌더, 자리 없음(작업 A 숨김 정책과 맞춤)
title?: string // tooltip/전체 이름
"aria-label"?: string
closeOnSelect?: boolean // 기본 true (Figma: 메뉴 클릭 → CLOSE)
onSelect?: (item: SWHomeMenuItemData) => void
}
export interface SWHomeMenuSectionData {
id: string
title: string
items: readonly SWHomeMenuItemData[]
hidden?: boolean
}
export interface SWHomeMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
sections?: readonly SWHomeMenuSectionData[] // 또는 children(<SWHomeMenuSection>)
open?: boolean // controlled
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
onItemSelect?: (item: SWHomeMenuItemData, section: SWHomeMenuSectionData) => void
/** 닫힐 때 Focus를 돌려줄 대상(기본: 연결된 홈 버튼). 통합 단계에서 SWHeader가 자동 연결 */
returnFocusRef?: RefObject<HTMLElement>
}
export function SWHomeMenu(props: SWHomeMenuProps): JSX.Element
export function SWHomeMenuSection(props: { title: string; children: ReactNode; className?: string }): JSX.Element
export function SWHomeMenuItem(props: ButtonHTMLAttributes<HTMLButtonElement> & { label: string; icon?: ReactNode; iconHover?: ReactNode }): JSX.Element
```
- **텍스트·아이콘·메뉴·이벤트·상태는 모두 외부에서 지정합니다.** 패키지에는 CAD 동작이 없습니다. Figma의 15개 메뉴는 Storybook fixture(또는 선택형 기본 아이콘 세트, §6 A-2)로만 제공합니다.
- **독립 단계 연결 방식(SWHeader 무수정):** `<SWHeader onHomeClick={() => setOpen(v => !v)} />`와 `<SWHomeMenu open onOpenChange />`를 같은 상대 위치 컨테이너 안에 둡니다. 위치는 host가 `style`로 지정합니다.
- **통합 단계 연결 방식(안):** `SWHeader`에 `homeMenu?: SWHomeMenuProps | ReactNode`를 추가합니다. 홈 버튼에 `aria-expanded`/`aria-controls`를 붙이고, 객체 영역 시작 x에 앵커하며, 열림 중 홈 버튼 상태를 표시합니다. 작업 A의 `leading` 슬롯·파일 열기 배치와 같은 파일을 수정하므로 통합 단계에서만 합니다.
- **CSS:** 새 파일 `packages/sw/src/theme/sw-home-menu.css`에 `.hm-sw-home-menu*`만 둡니다. 새 토큰은 `--hm-sw-home-menu-*`이고, 기존 `--hm-sw-header-text-outline`·`--hm-sw-header-label-outline`·`--hm-sw-header-focus-color`·font 토큰은 그대로 읽습니다. 전역 선택자와 Dark 분기는 두지 않습니다. 단독 사용 시 `<SWTheme>` 안에서 렌더합니다.
- **Font Weight:** 버튼 라벨 700(기존 "값·제목·토글 700"과 Figma 'Dotum/bold' 명칭), 제목 badge 12px 400(Stroke 없는 검정 글자)을 제안합니다. 600은 쓰지 않습니다. 최종값은 §5 R-5에서 확인합니다.
### 4.4 작업 A와 겹치는 부분
| 작업 A 항목 | 겹침 | 제안 |
|---|---|---|
| `SWToolButton` appearance pill/text/document | 80×46 세로 아이콘+라벨 타일은 승인된 appearance에 없습니다 | B는 `SWHomeMenuItem`을 독립으로 만듭니다. 통합 단계에서 `SWToolButton appearance="tile"`로 합칠지 정합니다(**별도 승인 대상** A-1) |
| `SWToolGroup` selection="single" | 메뉴에는 선택 상태가 없습니다(V3 의미 미정) | 사용하지 않습니다. 키보드 이동 규칙만 맞춥니다 |
| `SWPopup`/`Section`/`Row` | 밝은 속성창 Popup과 시각·역할이 다릅니다 | 재사용하지 않습니다. 이름이 비슷한 `Section`은 namespace(`SWHomeMenuSection`)로 구분합니다 |
| `hiddenControls`·`show*` | 항목 숨김 개념이 같습니다 | 항목·섹션 `hidden`(미렌더, 자리 없음)으로 A와 같은 의미를 씁니다 |
| `documentOpenPlacement`, 문서 New/Open | "찾아열기·새파일"이 `onDocumentOpen`/`onDocumentNew`와 중복될 수 있습니다 | **검토 대상**(R-7). 컴포넌트는 콜백만 받습니다 |
| `leading` 슬롯·파일 열기 배치·반응형 | 메뉴 시작 x(Figma 375 = 문서 제목 끝)가 바뀝니다 | 통합 단계에서 실제 DOM 위치로 앵커합니다(x 하드코딩 금지) |
| 속성Bar(`.hm-sw-property-bar`) | 같은 49px 아래 overlay 영역을 씁니다(메뉴는 77px까지) | z-index·동시 표시는 R-8 |
## 5. 검토 대상 (Figma에 없는 동작 — 확정하지 않음)
| ID | 항목 | 제안(미확정) |
|---|---|---|
| R-1 | 메뉴 닫기: 바깥 클릭·Escape·로고 재클릭·Tab 이탈 | 기존 SW Dropdown 계약과 같게 처리하고 Focus는 홈 버튼으로 복귀 |
| R-2 | 항목 클릭 후 닫힘 | Figma CLOSE를 따라 기본 닫힘, `closeOnSelect`로 변경 |
| R-3 | V3(클릭 후)의 의미: 누름(pressed) 순간 표시인지, 유지되는 선택 상태인지. 최근파일·S/W정보의 reaction 누락 | 누름(`:active`) 표시로만 구현하고 유지 상태는 만들지 않음 |
| R-4 | Disabled·Focus-visible 시각 | 기존 SW 규칙(opacity .4, focus 2px `--hm-sw-header-focus-color`) |
| R-5 | 라벨·제목 Font Weight(700/400), Stroke 적용 범위 | §4.3 제안 |
| R-6 | 접근성 역할: `role="menu"` + 화살표 이동 vs region + button + Tab | button 목록 + 섹션 `aria-labelledby`, 화살표 이동은 작업 A 규칙과 맞춤 |
| R-7 | 찾아열기·새파일과 기존 `onDocumentOpen`/`onDocumentNew` 관계, 최근파일 하위 목록 여부 | 컴포넌트는 콜백만 받고, 연결은 host 또는 통합 단계에서 결정 |
| R-8 | 속성Bar·Dropdown·K 미리보기와의 z-index와 동시 표시 | 메뉴가 열리면 다른 Dropdown을 닫고 속성Bar보다 위에 표시 |
| R-9 | 1920 미만 반응형(메뉴 1305px) | 가용 폭 안 내부 가로 스크롤(속성Bar 정책과 동일). 섹션 접기·줄바꿈은 새 디자인이 필요해 A-4로 분리 |
| R-10 | 하단 강조 strip 두 가지(섹션별 vs 전체 2px) 중 기준 | 기본 디자인 컴포넌트인 26:10591(섹션별) |
| R-11 | home on의 border-top/bottom `#5F4B0F`와 홈 버튼 배경 asset이 기존 Header와 다른지 | 2단계에서 기존 `home-bg*.svg`·Header 선과 대조한 뒤 보고 |
| R-P1 | 속성Bar 라벨 고정 박스 여백(P4) | 글꼴 대체 영향이라 유지 권장 |
| R-P2 | 속성Bar 세로 0.5px(P5) | 유지 권장 |
## 6. 별도 승인 대상 (새 디자인 판단 또는 승인 범위 초과)
| ID | 항목 | 이유 |
|---|---|---|
| A-1 | `SWToolButton`에 `tile` appearance 추가 또는 `SWHomeMenuItem` 별도 유지 | 작업 A에서 확정된 공개 API 변경 |
| A-2 | Figma 메뉴 아이콘 15개(+hover 15개)를 패키지 기본 asset으로 제공할지, Story fixture로만 둘지 | 공개 asset·번들 크기 증가 |
| A-3 | `SWHeader`에 `homeMenu` prop·홈 버튼 열림 상태 추가 | 기존 Header API·스타일 변경(통합 단계) |
| A-4 | 좁은 폭에서 섹션 접기·More 이동 등 새 반응형 디자인 | Figma에 없는 디자인 |
| A-5 | Radix 등 새 dependency 사용 | 현재 `@hm-design/sw`에는 런타임 의존성이 없음 |
| A-6 | 속성Bar P1–P3 수정 | 기존 공유 CSS 변경이며, 사용자 확정 디자인 범위 안인지 확인 필요 |
## 7. 파일 경계
| 구분 | 파일 | 내용 |
|---|---|---|
| **B 독립(신규만)** | `packages/sw/src/components/sw-home-menu/**` | `SWHomeMenu.tsx`, `SWHomeMenuSection.tsx`, `SWHomeMenuItem.tsx`, `types.ts`, `index.ts`, (A-2 승인 시) `assets/` |
| | `packages/sw/src/theme/sw-home-menu.css` | `.hm-sw-home-menu*` 스타일·토큰 |
| | `src/sw/sw-home-menu.stories.tsx` | Figma 21개 재현, 단독, 동적 데이터, Header 조합(host wiring) Story. Story CSS는 새 파일만 |
| | `docs/sw/figma-additional/**` | 이 보고서, 수집 자료, 2단계 검증 기록 |
| **통합 단계로 미룸** | `packages/sw/src/index.ts`, 테마 진입 CSS(`sw.css` import 체인) | export·CSS 연결(2단계 Story는 상대 경로 import로 확인) |
| | `packages/sw/src/components/sw-header/**`, `packages/sw/src/theme/sw.css` | `homeMenu` 연결(A-3), 속성Bar P1–P3(A-6) |
| | `build.mjs`, `package.json` | asset·CSS 번들 포함 |
| | `packages/sw/ai/**`, `packages/sw/README.md`, `docs/sw/sw-header-*.md` | AI 가이드·카탈로그·사용법. 2단계에서는 초안을 `docs/sw/figma-additional/`에 둠 |
| | 기존 stories(`src/sw/sw-header*.stories.tsx`, `sw-header-overview.tsx`, `sw-stories.css`) | 수정하지 않음 |
## 8. 2단계 구현·검증 계획 (승인 후)
**구현 순서**
1. Asset 재수집(만료 전) → A-2 결정에 따라 경로 확정 → 섹션 badge·그룹 box·구분선·타일 CSS
2. `SWHomeMenuItem` → `SWHomeMenuSection` → `SWHomeMenu`(open 계약, R-1·R-2 결정 반영)
3. Story `SW/Home Menu`: Figma 재현, 단독, 동적, Header 조합
4. AI 가이드 초안(`docs/sw/figma-additional/ai-home-menu-draft.md`) → 통합 단계에서 `packages/sw/ai/`로 이동
**검증 범위**(실행하지 않은 항목은 미검증으로 기록)
| 범위 | 방법 | 기준 |
|---|---|---|
| Figma 비교 | 1920 viewport, Figma 스크린샷과 같은 배율 비교 + 요소 실측 | 섹션 x·폭(340/260/180/260/261), 높이 75·46·19, 색·gradient·shadow |
| 21개 재현 | 제목 5개(참고 1개 포함), 버튼 15개 × Default/Hover/Pressed 상태 Story | 노드별 체크리스트 |
| 단독 사용 | `<SWTheme>` 안 `SWHomeMenu`만 렌더 | Header 없이 동작·스타일 유지, 전역 CSS 누수 없음 |
| 동적 콘텐츠 | 항목 추가·삭제·숨김, 긴 라벨, 아이콘 없음, 섹션 1개·6개, 비동기 갱신 | 폭·구분선·radius 자동 계산, 말줄임 |
| Header/Popup 조합 | `onHomeClick` host wiring, 속성Bar·Dropdown·K 가이드 동시 상태 | 닫기 계약, Focus 복귀, z-index(R-8) |
| 반응형 | 1920/1440/1280/1080/900/768/640/390 | 가로 넘침 없이 내부 스크롤(R-9) |
| Storybook | 기존 SW Story 회귀 없음 | 기존 Story 무수정 확인 |
| AI 가이드 | 문서만으로 새 메뉴 항목·섹션 생성 시나리오 1건 | 임의 값 없이 같은 스타일 |
| 독립 React 19 소비 프로젝트 | 통합 단계 build 후 packed tarball 설치 | 타입·CSS·asset 로드. **dist·export 연결이 통합 단계라 2단계에서는 미검증 예정** |
+115
View File
@@ -0,0 +1,115 @@
{
"source": {
"fileKey": "rj6wpsfk24lOWvWiVRAANP",
"collectedAt": "2026-10-19",
"tools": "공식 Figma MCP get_metadata / get_design_context / get_variable_defs / get_screenshot + 읽기 전용 use_figma(Plugin API: reactions, fills, strokes, effects, fonts)",
"page": "2. 헤더 (SECTION) — 속성Bar만 header_green 26:10332의 'Property 1=선택한것' 26:10491 하위",
"note": "get_design_context의 React/Tailwind 원문은 화면 재현용 참고 코드라 그대로 저장하지 않고, 구현에 필요한 값만 아래에 정규화했습니다. 원문은 같은 node id로 다시 받을 수 있습니다. 응답에 들어 있던 asset URL은 2026-10-26에 만료되므로 2단계에서 다시 받아야 합니다. 스크린샷은 screenshots/<node>.png (1x)입니다."
},
"nodes": [
{
"id": "26:10492", "name": "속성Bar", "type": "FRAME", "size": [1222, 28],
"screenshot": "screenshots/26-10492_property-bar.png",
"variables": { "--accent-yellow": "#FFC600", "--text-brown100": "#F6F4F2", "--text-red": "#E60403", "--gra-border-green": "(gradient, 값 없음)" },
"metadata": "Rectangle 7541 1222x28 + Frame(top 4, h 20) = 객체명 Dotum_14_bold 90x16 + Frame(gap 16) x4 [라벨 Dotum_regular_14 (46|30)x16 + gap 6 + input 58|66x20]",
"designContext": {
"background": "#271F13",
"border": "좌·우·하 1px (상 0) GRADIENT_LINEAR 위→아래 #08251C 0% / #208769 37.1% / #08241C 80.5% / #051612 100%",
"radius": "0 0 6 6",
"effect": "INNER_SHADOW 0 4 4 rgba(0,0,0,.6)",
"object": "90px 가운데 정렬, Dotum Regular 14 / 4%, #FFC600, 아래층 Stroke 검정 1.3px + 위층 #FFC600 0.3px",
"label": "Dotum Regular 14 / 4%, #F6F4F2, Stroke 검정 1px, 고정 폭 텍스트 박스(46 또는 30) 가운데 정렬",
"fieldGap": 16, "labelInputGap": 6,
"input": "component set 'input'(26:11656) Default 26:11657 58x20 pad 2/4/2/6 r2 면·테두리 없음 / hover 26:11659 58x24 1px rgba(212,203,189,.25) / on 26:11661 58x24 #0F0D0A + 1px .3. 값 Dotum Regular 14 / 2%, 검정 1.3 + #E60403 0.3"
},
"reactions": ["input 4개: ON_HOVER → CHANGE_TO input hover(26:11659)"]
},
{
"id": "26:10591", "name": "헤더/꾸러미 ver2/home on", "type": "COMPONENT", "size": [1920, 77],
"screenshot": "screenshots/26-10591_home-on.png",
"role": "로고(btn_home) 클릭 시 열리는 overlay 자체(TOP_LEFT, 배경 없음, 바깥 클릭 상호작용 없음)",
"variables": { "--text-brown100": "#F6F4F2", "--gra-border-brown1": "(gradient)", "--gra-border-brown2": "(gradient)", "--accent-border-yellow": "(gradient)" },
"hiddenLayers": ["bg balck 1920x48", "Rectangle 6671 310x48", "header/document_tit 310x48"],
"metadata": "창 영역 240x48(x1680) · 최소/종료 32x48 x2 · pill(인쇄 + 팝업가이드) 85x42 · 창이동 motion 88x48 · border-top/bottom 1920x2 #5F4B0F · btn_home 80x48 · Group 2882 x375 1305x77 = 하단 강조 strip(y47, 30px, 섹션별 사각형 5개) + 섹션 5개(x377 1301x75)",
"sections": [
{ "frame": "Frame 2408", "width": 340, "padding": "5 10 5 11", "title": "tit_작업중인파일 26:12604 124x19", "group": "g_작업중인파일 320x46", "items": ["btn_저장 26:11355", "복사저장 26:11371", "내보내기 26:11387", "도면묶기 26:11403"] },
{ "frame": "Frame 2409", "width": 260, "padding": "5 10", "title": "tit_다른파일열기 26:12610 123x19", "group": "g_다른파일열기 240x46", "items": ["찾아열기 26:11416", "최근파일 26:11450", "새파일 26:11485"] },
{ "frame": "Frame 2410", "width": 180, "padding": "5 10", "title": "tit_파일손상시 26:12622 111x19", "group": "g_파일손상시 160x46", "items": ["복구하기 26:11507", "임시파일 26:11529"] },
{ "frame": "Frame 2411", "width": 260, "padding": "5 10", "title": "tit_마이세팅 26:12628 107x19 (요청 목록 밖, 'My Setting')", "group": "Frame 2407 240x46", "items": ["디스플레이 26:11542", "저장하기 26:11558", "불러오기 26:11586"] },
{ "frame": "Frame 2412", "width": 261, "padding": "5 10 5 11", "title": "tit_궁금하면 26:12616 95x19", "group": "g_궁금하면 240x46", "items": ["설명보기 26:11599", "문의하기 26:11615", "소프트웨어 정보 26:11631"] }
],
"designContext": {
"section": "h75, 1px #081813, radius 0 0 3 3, fill 2중 gradient(세로 #18150E 0 / #262015 21.3% / #332B1C 47% / #38301F 75% / #18150E 100% + 가장자리 어둡게 #16120A 0 / 투명 54.5% / #16120A 100%), 안쪽 1px #20190E 사각형",
"group": "h46, 1px GRADIENT stroke(#886D35 0 / #423625 8.8% / #F3DBA8 26.2% / #0E0B06 83.8% / #574B30 100%), radius 3, fill gradient(#19160E 2% / #38301F 43.7% / #463C29 67.7% / #2F281A 100%), DROP_SHADOW 0 2 radius 4 rgba(0,0,0,.6)",
"title": "Union 도형(위아래 반전) 19px, fill gradient #887757 → #A8997C 75.6% → #C3B79E, stroke 1px #7B6332 → #9F8D68, DROP_SHADOW 0 -2 radius 4 rgba(0,0,0,.4), 글자 Dotum Regular 12 / 2% #000 가운데, group 위에 -1px 겹침",
"bottomStrip": "y47 h30, 섹션별 사각형(343/263/181/262/264, -2px 겹침, radius 0 0 3 3) fill #FEC501 + GRADIENT #5F4B0F → #FF5100",
"headerLines": "border-top 0-2px, border-bottom 46-48px, #5F4B0F 1920px"
},
"reactions": [
"btn_home 26:10652 ON_CLICK → OVERLAY 26:10591",
"Group 2882(메뉴 전체) ON_CLICK → CLOSE",
"메뉴 버튼 15개 ON_HOVER → 각 Variant2",
"header/btn_최소최대화·btn_종료 ON_HOVER → hover",
"Component 743(인쇄) ON_HOVER → hover 26:224",
"꾸러미ver2/팝업가이드 ON_HOVER → Variant5 26:426 (209x186, SMART_ANIMATE)",
"창이동 motion ON_HOVER → 26:10258 SMART_ANIMATE",
"header/document_tit(숨김) MOUSE_ENTER → OVERLAY 26:10667 hover"
]
},
{
"id": "662:67267", "name": "헤더/꾸러미 ver2/home on", "type": "FRAME", "size": [1920, 77], "renderBounds": [1935, 193],
"screenshot": "screenshots/662-67267_home-on-popup-guide.png",
"role": "26:10591과 같은 구성의 독립 Frame. 팝업가이드가 Variant5(K 미리보기 209x186) 상태이고 하단 강조가 다름",
"diffFrom_26:10591": [
"하단 strip: 섹션별 사각형 4개 숨김, Rectangle 6991만 1305x30, fill(#FF5100) 숨김 + 2px GRADIENT stroke #FF5100 → #5F4B0F",
"꾸러미ver2/팝업가이드 = Variant5 (검정 미리보기, '다중인쇄/도면관리')",
"btn_home 클릭·메뉴 CLOSE reaction 없음 (정적 상태 화면)",
"btn_home 배경 asset이 26:10591과 다른 파일(a80fd vs 9931a)"
],
"reactions": ["창 버튼·인쇄·창이동·메뉴 버튼 15개 ON_HOVER만 (26:10591과 같은 대상의 다른 사본 id)"]
},
{ "id": "26:12604", "name": "tit_작업중인파일", "type": "COMPONENT", "size": [124, 19], "text": "작업중인 파일", "textBox": [78, 14], "screenshot": "screenshots/26-12604_tit-working-file.png" },
{ "id": "26:12610", "name": "tit_다른파일열기", "type": "COMPONENT", "size": [123, 19], "text": "다른파일 열기", "textBox": [78, 14], "screenshot": "screenshots/26-12610_tit-open-other.png" },
{ "id": "26:12622", "name": "tit_파일손상시", "type": "COMPONENT", "size": [111, 19], "text": "파일 손상시", "textBox": [66, 14], "screenshot": "screenshots/26-12622_tit-file-damaged.png" },
{ "id": "26:12616", "name": "tit_궁금하면", "type": "COMPONENT", "size": [95, 19], "text": "궁금하면", "textBox": [49, 14], "screenshot": "screenshots/26-12616_tit-curious.png" }
],
"titleWidthRule": "제목 폭 = 글자 폭 + 약 46px(좌우 약 23px). 5개 모두 같은 Union 도형을 폭만 늘린 것",
"buttonSets": {
"common": {
"type": "COMPONENT_SET 120x218, variants 'Property 1' = Default / Variant2 / Variant3, 각 80x46",
"default": "면 없음, 아이콘 18x18 at (31,6) 회색 #B6B4AF 계열 SVG, 라벨 Dotum Regular 14 / 4% #F6F4F2 + 검정 1.3 / 자기 색 0.3 Stroke, top 25 가운데",
"variant2": "1px INSIDE GRADIENT stroke 위→아래 #DA6813 8.5% / #FFBB89 37.9% / #E5690D 71.9% / #AE3607 92.8%, 아이콘 hover SVG(#FF5C00), 라벨 #FF5C00, 아이콘·라벨 1px 위·왼쪽 이동(31,6→30,5 / top 25→24), radius = 그룹 첫 버튼 3 0 0 3, 마지막 0 3 3 0, 중간 0",
"variant3": "Variant2와 시각 속성 동일(Plugin API 전체 하위 노드 비교 결과 위치 y만 다름)",
"divider": "마지막 버튼을 뺀 버튼 오른쪽(-1px, hover -2px)에 1x44 세로선 GRADIENT(#886D35/#423625/#F3DBA8/#0E0B06/#574B30) + DROP_SHADOW -1 0 radius 2 #000",
"prototype": "Default ON_HOVER → Variant2, Variant2 ON_CLICK → Variant3, Variant3 ON_CLICK → Default"
},
"items": [
{ "set": "26:11355", "name": "btn_저장", "label": "저장", "icon": "save", "position": "first", "variants": ["26:11356", "26:11361", "26:11366"], "screenshot": "screenshots/26-11355_btn-save.png", "note": "Default에 숨김(visible=false) 노란 gradient stroke 존재" },
{ "set": "26:11371", "name": "복사저장", "label": "복사저장", "icon": "save_as", "position": "middle", "variants": ["26:11372", "26:11377", "26:11382"], "screenshot": "screenshots/26-11371_btn-save-copy.png" },
{ "set": "26:11387", "name": "내보내기", "label": "내보내기", "icon": "file_open", "position": "middle", "variants": ["26:11388", "26:11393", "26:11398"], "screenshot": "screenshots/26-11387_btn-export.png" },
{ "set": "26:11403", "name": "도면묶기", "label": "도면묶기", "icon": "inventory_2", "position": "last", "variants": ["26:11404", "26:11408", "26:11412"], "screenshot": "screenshots/26-11403_btn-bundle.png" },
{ "set": "26:11416", "name": "찾아열기", "label": "찾아열기", "icon": "folder_open (top 5)", "position": "first", "variants": ["26:11417", "26:11428", "26:11439"], "screenshot": "screenshots/26-11416_btn-find-open.png", "note": "구분선 gradient가 다른 버튼과 다름(0deg 갈색 #493927 계열)" },
{ "set": "26:11450", "name": "최근파일", "label": "최근파일", "icon": "insert_drive_file(시계 포함)", "position": "middle", "variants": ["26:11451", "26:11462", "26:11473"], "screenshot": "screenshots/26-11450_btn-recent.png", "note": "구분선 gradient 2중(갈색 + 초록). Variant2 → Variant3 클릭 reaction 없음" },
{ "set": "26:11485", "name": "새파일", "label": "새파일", "icon": "insert_drive_file", "position": "last", "variants": ["26:11486", "26:11493", "26:11500"], "screenshot": "screenshots/26-11485_btn-new-file.png" },
{ "set": "26:11507", "name": "복구하기", "label": "복구하기", "icon": "rotate_left", "position": "first", "variants": ["26:11508", "26:11515", "26:11522"], "screenshot": "screenshots/26-11507_btn-recover.png" },
{ "set": "26:11529", "name": "임시파일", "label": "임시파일", "icon": "file_copy", "position": "last", "variants": ["26:11530", "26:11534", "26:11538"], "screenshot": "screenshots/26-11529_btn-temp-file.png" },
{ "set": "26:11542", "name": "디스플레이", "label": "Display...", "icon": "monitor 24x22 at (29,6)", "position": "first", "variants": ["26:11543", "26:11548", "26:11553"], "screenshot": "screenshots/26-11542_btn-display.png", "note": "라벨 박스 70px, Figma 원문 자체가 '...' 말줄임" },
{ "set": "26:11558", "name": "저장하기", "label": "저장하기", "icon": "설정 저장(그룹 아이콘 16.3x16.5)", "position": "middle", "variants": ["26:11559", "26:11568", "26:11577"], "screenshot": "screenshots/26-11558_btn-setting-save.png", "note": "flex gap 2px" },
{ "set": "26:11586", "name": "불러오기", "label": "불러오기", "icon": "save_alt", "position": "last", "variants": ["26:11587", "26:11591", "26:11595"], "screenshot": "screenshots/26-11586_btn-setting-load.png" },
{ "set": "26:11599", "name": "설명보기", "label": "설명보기", "icon": "content_paste", "position": "first", "variants": ["26:11600", "26:11605", "26:11610"], "screenshot": "screenshots/26-11599_btn-help.png" },
{ "set": "26:11615", "name": "문의하기", "label": "문의하기", "icon": "question_answer", "position": "middle", "variants": ["26:11616", "26:11621", "26:11626"], "screenshot": "screenshots/26-11615_btn-inquiry.png" },
{ "set": "26:11631", "name": "소프트웨어 정보", "label": "S/W정보", "icon": "정보(Frame 2405)", "position": "last", "variants": ["26:11632", "26:11639", "26:11646"], "screenshot": "screenshots/26-11631_btn-sw-info.png", "note": "Variant3가 button 형태가 아니며 Default로 돌아가는 클릭 reaction 없음" }
]
},
"supplementary": {
"note": "요청 21개 밖, 2단계 재확인(2026-10-19)에서 찾은 노드. 같은 SECTION '2. 헤더'의 주석 '최근파일'(26:11784) 아래 662:67267과 함께 배치. 서로 연결된 prototype reaction 없음(drop ON_CLICK의 action 비어 있음).",
"nodes": [
{ "id": "662:67331", "name": "최근파일", "type": "FRAME", "size": [354, 750], "screenshot": "screenshots/662-67331_recent-files.png",
"position": "662:67267 기준 x 808, y 77 (최근파일 타일 왼쪽 + 1, 메뉴 바로 아래)",
"designContext": "frame 1px #DA6813 좌·우·하, radius 0 0 3 3 / drop 354x750 #27241D, 1px #27241D, radius 0 0 4 4, shadow 0 2 4 rgba(0,0,0,.25) + inset 0 4 8 rgba(0,0,0,.4) / row 'header/최근파일/list' 50px, px 8, gap 8 (배지 없는 행 px 45) / icon_dualmonitor 29x28 (84a46 왼쪽 노랑, 9967e 오른쪽 노랑) / 이름 Dotum 14 4% #F6F4F2 (Stroke 2층) / 경로 12 4% #D4CBBD 60% / Hover 행 #62533D + 이름 #FF5C00" },
{ "id": "662:67471", "name": "파일출력 2뎁스", "type": "FRAME", "size": [310, 270], "screenshot": "screenshots/662-67471_file-preview-2depth.png",
"position": "최근파일 오른쪽 +1, Hover 행 top +1",
"designContext": "#62533D, 1px #62533D 상·우·하, radius 0 3 4 0, drop-shadow 0 2 1 rgba(0,0,0,.5), inset 2 0 2.5 rgba(0,0,0,.5), py 4 gap 4 / 경로 14px #FF5C00 (아래층 #D7D2B0) / image 918 310x244 (crop 158.67% x 191.87%, -25.97% / -52.46%)" }
]
}
}
@@ -0,0 +1,40 @@
# Task A + B 통합 계획 — 로고 클릭 2단 Header 메뉴 (초안, 2026-10-19)
> 상태: **미승인 계획**입니다. Task B는 신규 파일만 작성했고 아래 공유 파일은 아직 수정하지 않았습니다. 통합은 사용자 별도 승인 후 진행합니다.
> 근거: [2단계 보고](./stage2-report.md), Task A 현황(읽기 전용 확인, 미커밋): SWHeader `leading` 슬롯, `documentOpenPlacement`, CSS 변수 `--hm-sw-header-leading-width`, `components/sw-tool/`.
## 1. 순서
1. Task A 변경을 먼저 확정한 뒤, 그 위에 Task B 신규 파일을 merge합니다. B 파일은 A 파일과 겹치지 않습니다.
2. export 연결 → 3. SWHeader 연결 → 4. 속성Bar 수정(선택) → 5. 문서·AI → 6. 검증.
## 2. 작업 목록과 적용 방법
| # | 파일 | 변경 | 적용 방법 |
|---|---|---|---|
| I-1 | `packages/sw/src/index.ts` | 공개 export | `export * from "./components/sw-home-menu"` 한 줄. CSS는 `SWHomeMenu.tsx`·`SWRecentFiles.tsx`에서 import하므로 index에 CSS import를 추가하지 않습니다(`sideEffects` 때문에 index에서만 import하면 빌드에서 빠지는 것을 확인함) |
| I-2 | `components/sw-header/types.ts` | Header prop | `homeMenu?: Omit<SWHomeMenuProps, "open" \| "onOpenChange" \| "returnFocusRef">`, `homeMenuOpen?`, `defaultHomeMenuOpen?`(false), `onHomeMenuOpenChange?(open, reason)`. 미전달이면 기존 동작과 같습니다(하위 호환) |
| I-3 | `components/sw-header/SWHeader.tsx` | 연결 | `homeMenu`가 있을 때만 홈 클릭으로 열림을 토글합니다. 기존 `onHomeClick`은 항상 먼저 호출합니다. 메뉴는 `<header>` 안의 absolute 레이어(`top: 0`)로 렌더링하고, `returnFocusRef`는 홈 버튼입니다. 메뉴가 열리면 기존 필드 Dropdown(`activeMenu`)을 닫습니다 |
| I-4 | 같은 파일 | 앵커 | 시작 x = 객체 수정 영역 `getBoundingClientRect().left − header.left − 2px`, 폭 = 창 영역 left − 그 값입니다. 고정 375px을 쓰지 않습니다. Task A `leading` 슬롯이 있으면 `--hm-sw-header-leading-width`만큼 객체 영역이 이동하므로 **DOM 실측을 기준으로 하고 변수는 보조로 씁니다**. 기존 속성Bar `barOffset` 측정 방식과 같은 패턴입니다 |
| I-5 | `components/sw-header/components/SWHeaderHome.tsx` | 접근성 | `aria-expanded`·`aria-controls`(메뉴 id)를 추가합니다. Figma에 열림 시각 변화가 없으므로 스타일은 바꾸지 않습니다 |
| I-6 | `theme/sw.css` | z-index | 메뉴 레이어를 속성Bar 앵커(z 2)보다 위, 기존 Dropdown 패널과 같은 층에 둡니다. 동시에 열리지 않게 I-3에서 닫습니다 |
| I-7 | `theme/sw.css` (선택, A-6 승인 필요) | 속성Bar | P1: 좌·우·하 테두리를 세로 gradient `#08251C 0% / #208769 37.1% / #08241C 80.5% / #051612 100%`로 바꿉니다(기존 hover-ring mask 기법). P2: `.hm-sw-property-bar__object`에 `--hm-sw-header-text-outline`. P3: 라벨에 `--hm-sw-header-label-outline` |
| I-8 | `theme/sw.css` (검토) | home on 상·하단 선 | Figma 열림 상태의 1920 전폭 `#5F4B0F` 2px 선(y 0–2, 46–48)을 `.hm-sw-header[data-home-menu-open]`에서만 적용할지 사용자가 확인해야 합니다 |
| I-9 | `build.mjs`, `package.json` | 번들 | 변경이 필요 없을 것으로 예상합니다. `dist/styles.css`에 `.hm-sw-home-menu`가 포함되는지, `title-badge.svg`가 inline 또는 asset으로 나오는지 build 후 확인합니다 |
| I-10 | `packages/sw/ai/**`, `README.md`, `docs/sw/sw-header-usage.md` | 문서 | [AI 가이드 초안](./ai-home-menu-guide.md)을 COMPONENT_CATALOG·COMPOSITION_PATTERNS·DESIGN_RULES로 옮기고, `homeMenu` prop 사용 예를 추가합니다 |
| I-11 | 기존 stories | 예시 | `src/sw/sw-home-menu.stories.tsx`의 소스 경로 import를 `@hm-design/sw`로 바꿉니다. host wiring 예시는 I-3 이후 `SWHeader homeMenu` 예시로 대체합니다 |
## 3. Task A와 맞출 결정 (사용자 확인)
- `SWToolButton`에 `tile` appearance를 둘지, `SWHomeMenuItem`을 별도로 유지할지(A-1).
- 토글 이름: A는 `pressed`, B는 `active`/`activeIds`(별칭 추가 여부).
- `documentOpenPlacement` 기본값 `"menu"`는 메뉴와 독립적입니다. 찾아열기·새파일은 host가 `onDocumentOpen`·`onDocumentNew`와 같은 handler를 연결합니다(내부 결합 없음).
- Figma 아이콘 15쌍을 패키지 asset으로 둘지 여부(A-2). 현재는 Story fixture에만 있습니다.
## 4. 통합 검증
1920·1440·1080·768·390 폭에서 확인합니다.
- 로고 열기·재클릭 닫기, 그리고 `leading`·문서 Dropdown(New/Open)과 겹치지 않는지.
- 기존 Header·속성Bar·Dropdown·K 가이드 회귀 없음.
- `tsc`, library build, packed tarball을 React 19 소비 프로젝트에 설치했을 때 동작하는지.
- Storybook 전체 Story(포트 충돌 주의).
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+129
View File
@@ -0,0 +1,129 @@
# 작업 B 2단계 — 로고 클릭 2단 Header 메뉴 구현·검증 보고 (2026-10-19)
> 기준: 사용자 최종 정정(로고 클릭 = 2단 Header 메뉴, 기본 숨김, 재클릭 닫힘), [1단계 분석](./analysis-report.md), Figma `rj6wpsfk24lOWvWiVRAANP`.
> 범위: Task B worktree `claude/sw-figma-logo-menu-36832b`(HEAD `5faa1f1`)의 **신규 파일만** 수정했습니다. Task A 공유 파일은 수정하지 않았고, Commit·Push·Merge·Publish도 하지 않았습니다.
## 0. 해석 변경 이력
| 시점 | 해석 | 최종 |
|---|---|---|
| 1단계 | 로고 클릭 → `home on` 2단 메뉴 overlay | **유지(최종 정정과 일치)** |
| 2단계 중간 요청 | 로고 클릭 → "파일 열기 창" | Figma 재확인 결과 앱 내부 UI이며 OS 파일 선택창 호출 구조가 아닙니다. 이후 사용자 최종 정정으로 2단 메뉴로 돌아왔습니다 |
| 재확인으로 바뀐 점 | 662:67267을 단순 "하단 강조 outline 변형"으로 본 것 | 실제로는 Figma 주석 **"최근파일" 상태 화면**입니다. 최근파일이 열려 메뉴 전체에 주황 외곽선이 생기고, 그 아래 최근파일 목록(662:67331)과 2뎁스 미리보기(662:67471)가 붙습니다. 두 노드는 요청한 21개 목록 밖이라 **선택 기능**(`panel` + `SWRecentFiles`)으로 구현했습니다 |
## 1. 구현 컴포넌트
| 파일(모두 신규) | 내용 |
|---|---|
| `packages/sw/src/components/sw-home-menu/SWHomeMenu.tsx` | `SWHomeMenu`(메뉴·열림·Active·닫기·키보드), `SWHomeMenuSection`(제목 badge + 그룹), `SWHomeMenuItem`(80×46 타일, Default / Variant2 / Variant3), `useSWHomeMenuClose` |
| `…/sw-home-menu/SWRecentFiles.tsx` | 선택 하위 패널: 최근파일 목록 354px, 행 50px, 2뎁스 미리보기 310×270 |
| `…/sw-home-menu/types.ts`, `index.ts` | 공개 타입·export |
| `…/sw-home-menu/assets/title-badge.svg` | Figma tit_* Union 도형(9-slice로 폭 가변) |
| `packages/sw/src/theme/sw-home-menu.css` | `.hm-sw-home-menu*`, `.hm-sw-recent-files*` 스타일과 `--hm-sw-home-menu-*`·`--hm-sw-recent-files-*` 토큰. 기존 SW 토큰(글꼴, 외곽선, focus, 행 Hover)을 그대로 읽습니다 |
| `src/sw/sw-home-menu.stories.tsx`, `sw-home-menu-fixture.tsx`, `sw-home-menu.css`, `src/sw/assets/home-menu/**` | Storybook `SW/Home Menu`(Figma 재현, Header 조합, 3 Variant, 데이터 변경, 단독, 최근파일, 속성Bar 비교, 비교 캔버스). Figma 아이콘 30개·미리보기 이미지는 **Story fixture 전용**입니다 |
| `docs/sw/figma-additional/ai-home-menu-guide.md`, `stage2-report.md`, `stage2-qa/**`, `screenshots/662-67331·662-67471` | AI 가이드 초안, 이 보고서, 검증 스크린샷 |
## 2. Figma 노드 대응 (22 + 참고 3)
| Node | 대응 | 상태 |
|---|---|---|
| 26:10492 속성Bar | 기존 `SWHeaderPropertyBar`(수정 없음). Story `속성Bar 비교`에서 Figma 값으로 비교 | 차이 3건은 통합 단계(§8) |
| 26:10591 home on | `SWHomeMenu` + `accent="auto"`(패널 없음 → 섹션별 강조) | 재현 |
| 662:67267 home on(최근파일 상태) | 최근파일 타일 Active + 패널 → `accent` 자동 외곽선. K 미리보기는 기존 `SWHeaderKGuide` Hover | 재현 |
| 26:12604 / 12610 / 12622 / 12616 (+26:12628 참고) | `SWHomeMenuSection.title` (badge 공통 1개) | 재현 |
| 26:11355 … 26:11631 버튼 세트 15개 | `SWHomeMenuItem` 1개 + Story 데이터 15건. Default / V2(Hover) / V3(Active) | 재현 |
| 662:67331 최근파일 / 662:67471 2뎁스 (참고) | `SWRecentFiles` (`panel`로 연결) | 재현(선택 기능) |
## 3. 공개 API
```ts
<SWHomeMenu
sections?: SWHomeMenuSectionData[] // 또는 children(<SWHomeMenuSection>/<SWHomeMenuItem>)
open? defaultOpen?=false onOpenChange?(open, reason: "escape"|"background"|"outside"|"select"|"file")
activeIds? defaultActiveIds? onActiveChange?(ids, event) selection?: "multiple"|"single"
onItemSelect?(event) closeOnBackgroundClick?=true closeOnSelect?=false closeOnEscape?=true closeOnOutsideClick?=false
autoFocus?=true returnFocusRef? accent?: "auto"|"sections"|"outline" />
SWHomeMenuItemData { id, label, icon?, hoverIcon?, toggle?=true, panel?, panelLabel?, disabled?, hidden?, title?, aria-label?, onSelect? }
SWHomeMenuSectionData { id, title, items, hidden? }
<SWRecentFiles files: SWFileEntry[] onFileOpen? onFileHover? renderPreview? closeOnOpen?=true emptyText? />
SWFileEntry { id, name, path?, badge?, preview?, previewTitle?, disabled?, title? }
```
- 텍스트·아이콘·항목·이벤트·Active·하위 패널은 모두 외부 데이터입니다. 패키지에는 메뉴 항목이나 CAD 동작이 들어 있지 않습니다.
- 단독 사용: `<SWTheme>` 안에 둡니다. 메뉴 밖에서 쓰는 `SWHomeMenuItem`도 `active`·`onActiveChange`로 동작합니다.
- Header 연동(통합 전): 기존 `SWHeader onHomeClick`으로 `open`을 토글합니다. 위치는 host가 Header DOM(객체 수정 영역 왼쪽 − 2px부터 창 영역 왼쪽까지)에서 측정합니다.
## 4. Figma 디자인 일치
1x 캡처는 headless Chromium의 1920px 화면에서 Figma 스크린샷과 2배 확대로 비교했습니다(`stage2-qa/compare-*.png`).
| 항목 | 결과 |
|---|---|
| 위치·크기 | 1920에서 메뉴 left 375, 폭 1305, 높이 77이며 섹션 폭은 340/260/180/260/261입니다. 그룹 46, 타일 80×46, 아이콘 18 슬롯(31,6), 라벨 top 25 **일치** |
| 색·gradient·그림자 | 섹션 면·안쪽 선, 그룹 테두리 gradient(위→아래)·면(아래→위)·그림자, 구분선, 주황 Hover 테두리, 하단 강조 2종 **일치**(Plugin API 값과 gradientTransform 방향 반영) |
| 아이콘 | Figma SVG 30개(Default·Hover)를 그대로 사용하고, 위치 보정 값은 fixture에 둠 **일치** |
| 최근파일·미리보기 | 354 폭, 행 50, 배경 `#27241D`, 주황 테두리, Hover 행 `#62533D` + `#FF5C00`, 미리보기 310×270 **일치**(DPR 1.5 화면에서는 테두리 반올림으로 269.3) |
| 의도된 차이 | 글꼴은 Dotum → Noto Sans KR이고, 타일 라벨·파일명은 700(기존 SW 정책)이라 Figma보다 굵습니다. 섹션 제목은 400이며 Figma보다 가늘고 badge가 7px 좁습니다(116.6 vs 124). 첫 섹션 면 gradient stop은 나머지 4개 기준으로 통일했습니다. 찾아열기·최근파일 구분선의 다른 gradient는 공통 구분선으로 통일했습니다 |
| Header 소유 차이 | home on의 1920 전폭 상·하단 `#5F4B0F` 2px 선은 Header 소유라 미구현(통합 검토) |
## 5. 상태 전환·Overlay 테스트 (Storybook 6031, 실제 실행)
| 테스트 | 결과 |
|---|---|
| 기본 숨김 | PASS (`hiddenByDefault: true`, consumer DOM에도 없음) |
| 로고 클릭 열기 / 재클릭 닫기 | PASS |
| Hover → V2 (실제 마우스) | PASS (테두리 opacity 1, `#FF5C00`, Hover 아이콘 표시, -1px 이동) |
| 클릭 → V3, 재클릭 → Default | PASS (`aria-pressed` true/false) |
| 메뉴 빈 곳 클릭 닫기 / Escape 닫기 + 로고 Focus 복귀 | PASS |
| ←/→/Home/End, Tab Focus 2px 윤곽선, Enter 실행 | PASS (실제 키 입력) |
| 최근파일: 열기 → 외곽선 강조, `aria-expanded`, 재클릭 닫기, ↓로 목록 진입, 행 Hover·Focus 미리보기, Enter/클릭 → `onFileOpen` + 메뉴 닫기, 다시 열면 Default | PASS |
| Escape 단계(패널 → 메뉴) | PASS |
| 데이터 변경(추가·삭제·숨김·disabled·긴 라벨·섹션 추가·single·accent) | PASS |
| 반응형 1920/1440/1080/768/390 | PASS: 로고·창 영역과 겹치지 않고 메뉴 안에서 가로 스크롤. 좁은 폭 접기 디자인은 없음(별도 승인) |
| 타입 | PASS: `tsc` SW 패키지, Story, 독립 consumer |
| 독립 React 19.3 consumer(Vite 빌드, 소스 import) | PASS: 렌더, 기본 숨김, CSS·SVG 번들 포함 |
참고: 브라우저 pane은 background page라 ResizeObserver 측정을 할 수 없어, 반응형은 headless Chromium으로 확인했습니다. 메뉴 CSS를 `index.ts`에서만 import하면 `sideEffects` 설정 때문에 빌드에서 빠지는 것을 발견했고, 사용하는 컴포넌트 파일에서 import하도록 고쳤습니다.
## 6. AI 재사용
[AI 가이드 초안](./ai-home-menu-guide.md) 기준입니다. 새 메뉴는 `sections` 데이터만으로 같은 스타일이 적용되고, 폭·구분선·모서리·badge 폭이 자동으로 계산됩니다. 소비 프로젝트에서 새 섹션 2개·항목 5개(currentColor 아이콘, `toggle:false`, 패널)를 만든 결과가 같은 스타일로 렌더링되는 것을 확인했습니다(`stage2-qa/react19-consumer.png`). AI 가이드를 `packages/sw/ai/`로 옮기는 작업은 통합 단계입니다.
## 7. Task A와의 중복·충돌
| 항목 | 내용 | 조치 |
|---|---|---|
| `SWToolButton` (pill/text/document) | 80×46 타일 appearance가 없어 중복 구현은 아닙니다 | 통합 때 `tile` appearance로 합칠지 결정(별도 승인) |
| 아이콘 prop 이름 | A `hoverIcon` | B도 `hoverIcon`으로 맞춤 |
| 토글 상태 이름 | A `pressed` / B `active`·`activeIds` | 메뉴는 여러 항목 상태라 `activeIds` 유지. 통합 때 별칭 검토 |
| CSS | A `hm-sw-tool__*` / B `hm-sw-home-menu__*`, `hm-sw-recent-files__*` | 충돌 없음 |
| `documentOpenPlacement` | 사용자 최종 지시: 기본 `"menu"`(Open은 문서 Dropdown 안), 로고 옆 독립 Open 없음 | B 메뉴는 이 값과 무관합니다. B Story의 Header(5faa1f1)에는 독립 Open이 없습니다 |
| `leading` 슬롯 | 객체 수정 영역 시작 x가 바뀝니다 | B는 DOM 측정이라 하드코딩 없음 |
| 찾아열기·새파일 ↔ `onDocumentOpen`·`onDocumentNew` | 동작이 중복될 수 있습니다 | 컴포넌트는 콜백만 받습니다. host가 같은 handler를 연결하는 방식을 권장합니다(통합 문서에 기재) |
## 8. 통합 시 수정할 공유 파일
| 파일 | 변경 |
|---|---|
| `packages/sw/src/index.ts` | `export * from "./components/sw-home-menu"` |
| `components/sw-header/SWHeader.tsx`, `types.ts` | (안) `homeMenu?: SWHomeMenuProps`, `homeMenuOpen`/`defaultHomeMenuOpen`/`onHomeMenuOpenChange`. 홈 버튼 `aria-expanded`·`aria-controls`, 객체 수정 영역 −2px ~ 창 영역 앵커, 열릴 때 다른 Dropdown 닫기, 기존 `onHomeClick` 유지(하위 호환) |
| `components/sw-header/components/SWHeaderHome.tsx` | 열림 상태 전달(시각 변화는 Figma에 없음) |
| `theme/sw.css` | 속성Bar P1 gradient 테두리, P2·P3 객체명·라벨 외곽선. home on 상·하단 `#5F4B0F` 선 적용 여부(검토) |
| `build.mjs`, `package.json` | 새 CSS가 `styles.css`에 포함되는지 확인(컴포넌트 import 방식이라 변경 불필요 예상, 미검증) |
| `packages/sw/ai/**`, `README.md`, `docs/sw/sw-header-usage.md` | AI 가이드 초안 반영 |
| 기존 stories | 필요하면 Header Overview에 메뉴 연결 |
## 9. 판정
**PARTIAL** — 독립 구현과 검증은 PASS입니다. 다만 공유 파일 연동(공개 export, `SWHeader` 내장 연결, 속성Bar 수정)은 승인 범위상 통합 단계로 남았습니다.
미검증 항목:
- 패키지 진입점(`@hm-design/sw`) export와 `build.mjs` dist의 `styles.css` 포함 여부. tarball 소비 검증도 하지 않았습니다(통합 단계).
- Task A 최신 Header(leading·documentOpenPlacement 반영본)와의 실제 조합. B는 5faa1f1 기준 Header로만 검증했습니다.
- Firefox·Safari 렌더링(Chromium만 확인).
- 속성Bar P1–P3를 수정한 뒤의 비교.
- 스크린리더 실제 낭독.
검토 대상: V3의 의미(유지 토글 vs 순간 누름; 현재는 사용자 지시대로 유지 토글), 파일 열기 후 닫기 기본값, 최근파일 패널 열기 방식(Figma 연결 없음 → 클릭), 빈 목록 문구, 좁은 폭 디자인, Header 상·하단 선.
로컬 검증 환경(커밋 대상 아님): worktree `node_modules` junction(→ HMDesign-SW), `.storybook-task-b/`(신규 Story만 로드하는 설정과 웹폰트 fs.allow), `.claude/launch.json`. 실행 중인 Storybook 포트는 6031입니다. 정리 여부는 사용자가 결정합니다.
@@ -0,0 +1,34 @@
{
"browser": "155.0.8059.80",
"shots": [
"overview-1920.png",
"detail-buttons-1920.png",
"interactive-states-1920.png",
"window-move-motion-1920.png",
"logo-slot-1920.png",
"document-dropdown-1920.png",
"responsive-more-1920.png",
"k-guide-1920.png",
"property-panel-compare-1920.png",
"composed-controls-1920.png",
"overview-1800.png",
"overview-1440.png",
"overview-1180.png",
"overview-1080.png",
"overview-900.png",
"overview-768.png",
"overview-640.png",
"overview-520.png",
"overview-390.png",
"responsive-more-1800.png",
"responsive-more-1440.png",
"responsive-more-1180.png",
"responsive-more-1080.png",
"responsive-more-900.png",
"responsive-more-768.png",
"responsive-more-640.png",
"responsive-more-520.png",
"responsive-more-390.png"
],
"errors": []
}
@@ -0,0 +1,34 @@
{
"browser": "155.0.8059.80",
"shots": [
"overview-1920.png",
"detail-buttons-1920.png",
"interactive-states-1920.png",
"window-move-motion-1920.png",
"logo-slot-1920.png",
"document-dropdown-1920.png",
"responsive-more-1920.png",
"k-guide-1920.png",
"property-panel-compare-1920.png",
"composed-controls-1920.png",
"overview-1800.png",
"overview-1440.png",
"overview-1180.png",
"overview-1080.png",
"overview-900.png",
"overview-768.png",
"overview-640.png",
"overview-520.png",
"overview-390.png",
"responsive-more-1800.png",
"responsive-more-1440.png",
"responsive-more-1180.png",
"responsive-more-1080.png",
"responsive-more-900.png",
"responsive-more-768.png",
"responsive-more-640.png",
"responsive-more-520.png",
"responsive-more-390.png"
],
"errors": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,363 @@
{
"browser": "155.0.8059.80",
"checks": [
{
"name": "Storybook SW titles: Header, Tool, Popup, Home Menu; no SW/Header/Options",
"status": "PASS",
"data": [
"SW/Header",
"SW/Home Menu",
"SW/Popup",
"SW/Tool"
]
},
{
"name": "menu at Figma x 375, width 1305, height 77 (measured, no fixed x); header stays 49",
"status": "PASS",
"data": {
"left": 375,
"width": 1305,
"height": 77,
"headerHeight": 49,
"sections": [
340,
260,
180,
260,
261
],
"tiles": 15,
"tileSizes": [
"80x46"
],
"titles": 5,
"badgeInlined": true,
"icons": 30,
"iconsLoaded": 30
}
},
{
"name": "5 sections 340/260/180/260/261, 15 tiles 80x46, 5 titles",
"status": "PASS",
"data": {
"left": 375,
"width": 1305,
"height": 77,
"headerHeight": 49,
"sections": [
340,
260,
180,
260,
261
],
"tiles": 15,
"tileSizes": [
"80x46"
],
"titles": 5,
"badgeInlined": true,
"icons": 30,
"iconsLoaded": 30
}
},
{
"name": "title badge SVG and 30 tile icons render",
"status": "PASS",
"data": {
"left": 375,
"width": 1305,
"height": 77,
"headerHeight": 49,
"sections": [
340,
260,
180,
260,
261
],
"tiles": 15,
"tileSizes": [
"80x46"
],
"titles": 5,
"badgeInlined": true,
"icons": 30,
"iconsLoaded": 30
}
},
{
"name": "hidden by default (not in DOM), logo aria-expanded=false",
"status": "PASS"
},
{
"name": "logo click opens, aria-expanded=true, aria-controls → menu id",
"status": "PASS"
},
{
"name": "tile Hover: label #FF5C00",
"status": "PASS",
"data": "rgb(255, 92, 0)"
},
{
"name": "tile click → Active (aria-pressed true), re-click → Default",
"status": "PASS",
"data": "true"
},
{
"name": "logo re-click closes; onHomeClick never called while homeMenu is set",
"status": "PASS",
"data": "onHomeMenuOpenChange(false, home)\nonItemSelect(working / save, active=false)\nonActiveChange([])\nonItemSelect(working / save, active=true)\nonActiveChange([save])\nonHomeMenuOpenChange(true, home)"
},
{
"name": "Escape inside closes the menu and returns focus to the logo",
"status": "PASS"
},
{
"name": "click on the menu background closes it (Figma CLOSE)",
"status": "PASS"
},
{
"name": "recent panel opens (outline accent); Escape closes the panel, then the menu",
"status": "PASS",
"data": {
"panelOpen": true,
"panelFirst": true
}
},
{
"name": "opening the document dropdown closes the menu (reason document); New / Open stay in the dropdown",
"status": "PASS",
"data": "C0020101-006-평면및종단면도(06)\nC0020101-007-횡단면도\n+ New\nOpen"
},
{
"name": "opening the menu closes the document dropdown",
"status": "PASS"
},
{
"name": "no separate Open button next to the logo (documentOpenPlacement=\"menu\")",
"status": "PASS"
},
{
"name": "focus outside: Escape closes the most recent (menu), then the popup",
"status": "PASS",
"data": {
"step1": {
"menu": 0,
"popup": 1
},
"step2": {
"menu": 0,
"popup": 0
}
}
},
{
"name": "popup opened after the menu: Escape outside closes the popup first, menu stays",
"status": "PASS",
"data": {
"menu": 1,
"popup": 0
}
},
{
"name": "Escape inside the popup closes only the popup",
"status": "PASS",
"data": {
"menu": 1,
"popup": 0
}
},
{
"name": "Escape inside the menu closes only the menu (popup stays), focus → logo",
"status": "PASS",
"data": {
"menu": 0,
"popup": 1,
"focusHome": true
}
},
{
"name": "every width: menu starts at object area −2px, ends at the window area, covers neither logo nor file name, header 49, no page scroll",
"status": "PASS",
"data": [
{
"width": 1920,
"headerHeight": 49,
"start": 0,
"endGap": 0,
"coversHome": false,
"coversDocument": false,
"menuWidth": 1305,
"scrolls": false,
"pageScroll": false
},
{
"width": 1440,
"headerHeight": 49,
"start": 0,
"endGap": 0,
"coversHome": false,
"coversDocument": false,
"menuWidth": 825,
"scrolls": true,
"pageScroll": false
},
{
"width": 1080,
"headerHeight": 49,
"start": 0,
"endGap": 0,
"coversHome": false,
"coversDocument": false,
"menuWidth": 639,
"scrolls": true,
"pageScroll": false
},
{
"width": 768,
"headerHeight": 49,
"start": 0,
"endGap": 0,
"coversHome": false,
"coversDocument": false,
"menuWidth": 288,
"scrolls": true,
"pageScroll": false
},
{
"width": 520,
"headerHeight": 49,
"start": 0,
"endGap": 0,
"coversHome": false,
"coversDocument": false,
"menuWidth": 288,
"scrolls": true,
"pageScroll": false
},
{
"width": 390,
"headerHeight": 49,
"start": 0,
"endGap": 0,
"coversHome": false,
"coversDocument": false,
"menuWidth": 165,
"scrolls": true,
"pageScroll": false
}
]
},
{
"name": "without homeMenu: logo click calls onHomeClick, renders no menu, no aria-expanded",
"status": "PASS",
"data": [
"[SWHeader Overview] onHomeClick"
]
},
{
"name": "no page / console errors",
"status": "PASS",
"data": []
}
],
"errors": [],
"data": {
"titles": [
"SW/Header",
"SW/Home Menu",
"SW/Popup",
"SW/Tool"
],
"geometry": {
"left": 375,
"width": 1305,
"height": 77,
"headerHeight": 49,
"sections": [
340,
260,
180,
260,
261
],
"tiles": 15,
"tileSizes": [
"80x46"
],
"titles": 5,
"badgeInlined": true,
"icons": 30,
"iconsLoaded": 30
},
"responsive": [
{
"width": 1920,
"headerHeight": 49,
"start": 0,
"endGap": 0,
"coversHome": false,
"coversDocument": false,
"menuWidth": 1305,
"scrolls": false,
"pageScroll": false
},
{
"width": 1440,
"headerHeight": 49,
"start": 0,
"endGap": 0,
"coversHome": false,
"coversDocument": false,
"menuWidth": 825,
"scrolls": true,
"pageScroll": false
},
{
"width": 1080,
"headerHeight": 49,
"start": 0,
"endGap": 0,
"coversHome": false,
"coversDocument": false,
"menuWidth": 639,
"scrolls": true,
"pageScroll": false
},
{
"width": 768,
"headerHeight": 49,
"start": 0,
"endGap": 0,
"coversHome": false,
"coversDocument": false,
"menuWidth": 288,
"scrolls": true,
"pageScroll": false
},
{
"width": 520,
"headerHeight": 49,
"start": 0,
"endGap": 0,
"coversHome": false,
"coversDocument": false,
"menuWidth": 288,
"scrolls": true,
"pageScroll": false
},
{
"width": 390,
"headerHeight": 49,
"start": 0,
"endGap": 0,
"coversHome": false,
"coversDocument": false,
"menuWidth": 165,
"scrolls": true,
"pageScroll": false
}
]
}
}
@@ -0,0 +1,146 @@
{
"total": 28,
"same": 28,
"results": [
{
"file": "composed-controls-1920.png",
"status": "SAME",
"changed": 0
},
{
"file": "detail-buttons-1920.png",
"status": "SAME",
"changed": 0
},
{
"file": "document-dropdown-1920.png",
"status": "SAME",
"changed": 0
},
{
"file": "interactive-states-1920.png",
"status": "SAME",
"changed": 0
},
{
"file": "k-guide-1920.png",
"status": "SAME",
"changed": 0
},
{
"file": "logo-slot-1920.png",
"status": "SAME",
"changed": 0
},
{
"file": "overview-1080.png",
"status": "SAME",
"changed": 0
},
{
"file": "overview-1180.png",
"status": "SAME",
"changed": 0
},
{
"file": "overview-1440.png",
"status": "SAME",
"changed": 0
},
{
"file": "overview-1800.png",
"status": "SAME",
"changed": 0
},
{
"file": "overview-1920.png",
"status": "SAME",
"changed": 0
},
{
"file": "overview-390.png",
"status": "SAME",
"changed": 0
},
{
"file": "overview-520.png",
"status": "SAME",
"changed": 0
},
{
"file": "overview-640.png",
"status": "SAME",
"changed": 0
},
{
"file": "overview-768.png",
"status": "SAME",
"changed": 0
},
{
"file": "overview-900.png",
"status": "SAME",
"changed": 0
},
{
"file": "property-panel-compare-1920.png",
"status": "SAME",
"changed": 0
},
{
"file": "responsive-more-1080.png",
"status": "SAME",
"changed": 0
},
{
"file": "responsive-more-1180.png",
"status": "SAME",
"changed": 0
},
{
"file": "responsive-more-1440.png",
"status": "SAME",
"changed": 0
},
{
"file": "responsive-more-1800.png",
"status": "SAME",
"changed": 0
},
{
"file": "responsive-more-1920.png",
"status": "SAME",
"changed": 0
},
{
"file": "responsive-more-390.png",
"status": "SAME",
"changed": 0
},
{
"file": "responsive-more-520.png",
"status": "SAME",
"changed": 0
},
{
"file": "responsive-more-640.png",
"status": "SAME",
"changed": 0
},
{
"file": "responsive-more-768.png",
"status": "SAME",
"changed": 0
},
{
"file": "responsive-more-900.png",
"status": "SAME",
"changed": 0
},
{
"file": "window-move-motion-1920.png",
"status": "SAME",
"changed": 0
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+17
View File
@@ -633,3 +633,20 @@ Storybook: `Property Panel · 선택 없음 / 전체 선택 / 원 1개 / 모두
- `undefined`(미지정): Figma 원형 그림(기존 기본). `null` / `false`: 빈 영역 — 크기·치수 라벨 위치·패널 높이 유지. ReactNode: 외부 컴포넌트. 함수: `{ selection, group, properties }`(해당 그룹 항목)를 받아 렌더링 — 객체 유형별 교체에 사용합니다.
- 내용은 영역 안에 맞춰지고(overflow hidden) 원주·지름·반지름·면적 입력은 그 위에 그대로 표시됩니다. 값은 `properties`로만 전달되며 형상 Slot과 독립입니다. 입력은 기존 SWHeaderInput 검증·Enter/Blur 확정·Escape 취소를 사용합니다.
- 패키지는 CAD 계산·형상 렌더링 엔진을 포함하지 않습니다. Storybook의 사각형/점선 원 SVG는 Story 전용 샘플입니다.
## 로고 클릭 메뉴 — `homeMenu` (2026-10-19)
Figma 26:10591의 2단 Header 메뉴입니다. 기본 숨김, 로고 클릭으로 열고 다시 클릭하면 닫힙니다.
```tsx
<SWHeader documentTitle={name} documents={documents} onDocumentNew={newFile} onDocumentOpen={openFile}
homeMenu={{ sections, onItemSelect }} // SWHomeMenu Props (열림 상태 제외)
onHomeMenuOpenChange={(open, reason) => {}} /> // reason: home | escape | background | outside | select | file | document
```
- `sections`: `{ id, title, items: [{ id, label, icon, hoverIcon?, toggle?, panel?, disabled?, hidden?, onSelect? }] }[]`. 최근파일은 타일의 `panel: <SWRecentFiles files={…} onFileOpen={…} />`(선택).
- 이벤트 우선순위: `homeMenu`가 있으면 로고 클릭은 메뉴 토글만 하고 `onHomeClick`은 호출되지 않습니다. `homeMenu`가 없으면 기존과 같이 `onHomeClick`만 호출됩니다.
- New / Open은 문서 Dropdown 안 그대로입니다(`documentOpenPlacement` 기본 `"menu"`). 메뉴가 열리면 필드·문서 Dropdown이 닫히고, 문서 Dropdown을 열면 메뉴가 닫힙니다.
- 위치: 객체 수정 영역 −2px ~ 창 영역(DOM 측정, 1920에서 x 375 · 폭 1305). 좁은 폭에서는 메뉴 안 가로 스크롤.
- Escape: 하위 패널 → 메뉴 순서, 닫히면 로고로 Focus. 속성창·`SWPopup`과 같은 Escape 순서(Focus가 모두 밖이면 최근 사용 하나).
- 상세: 패키지 [카탈로그 5절](../../packages/sw/ai/COMPONENT_CATALOG.md), [통합 검증 기록](./sw-home-menu-integration-2026-10-19.md).
@@ -0,0 +1,38 @@
# SW Task A + B 통합 — 로고 클릭 메뉴 (2026-10-19)
> 통합 Worktree `HMDesign-SW-Integration`, 브랜치 `integration/sw-home-menu`(기준 Task A `e8409ef`) + Task B `396539d`의 신규 파일 84개. 미커밋.
## 결합 방식
- Task B 파일 84개는 모두 신규이며 Task A 파일과 겹치지 않아 Git 충돌이 없습니다(`5faa1f1` → `e8409ef` 조상 관계).
- 공유 파일 수정은 연결에 필요한 최소 범위입니다.
| 파일 | 변경 |
|---|---|
| `packages/sw/src/index.ts` | `export * from "./components/sw-home-menu"` |
| `sw-header/types.ts`, `index.ts` | `homeMenu`, `homeMenuOpen`, `defaultHomeMenuOpen`, `onHomeMenuOpenChange`, 타입 `SWHeaderHomeMenu`, `SWHeaderHomeMenuChangeReason` |
| `sw-header/SWHeader.tsx` | `homeMenu`가 있을 때만: 로고 클릭 = 메뉴 토글(`onHomeClick` 미호출), DOM 측정 배치(객체 수정 영역 −2px ~ 창 영역), 메뉴와 문서·필드 Dropdown 상호 닫기. `homeMenu`가 없으면 기존 동작 그대로 |
| `components/SWHeaderHome.tsx` | `aria-expanded`·`aria-controls` 전달(시각 변화 없음) |
| `components/SWHeaderPropertyPanel.tsx` | 내부 `useSWPopupStack`에 `export`만 추가(패키지 공개 아님) |
| `theme/sw.css` | `.hm-sw-header__home-menu` 레이어 1줄(top 0, z-index 20) |
| `sw-home-menu/SWHomeMenu.tsx`(Task B) | 열린 동안 Popup Escape 스택에 합류(하위 패널 → 메뉴 순서는 그대로) |
| Story·Fixture(Task B) | `@hm-design/sw` 공식 Import, `SWHeader homeMenu` 사용, `통합 · Popup · Escape` Story 추가 |
| AI 문서·README·사용 가이드 | 카탈로그 5절, 조합 패턴 7절, 디자인 규칙 11절, 예제 G |
## 검증 (실행 결과)
| 항목 | 결과 |
|---|---|
| SW typecheck / 앱 `tsc -b` / SW build / Storybook build | PASS / PASS / PASS / PASS |
| `dist/styles.css` 홈 메뉴 스타일, `dist/index.d.ts` 공개 Export | 포함 / 포함 |
| AI 문서·공개 API 정합성(`ai-docs.check.mjs`) | 22/22 PASS |
| 기존 SW Story 28장 픽셀 비교(통합 전 기준선 대비, threshold 0) | 28/28 동일 |
| 기존 Header·Tool·Popup 기능 회귀(`integrated-improvement.browser.mjs`) | 96 PASS / 0 FAIL(통합 전과 동일) |
| 홈 메뉴 통합(`home-menu-integration.browser.mjs`) | 22/22 PASS |
## 남은 항목
- 미실행: 독립 React 19 Tarball 소비 설치, SW + HM UI CSS 동시 사용, Figma 스크린샷 픽셀 비교(배치·크기는 DOM 실측으로만 확인), Firefox·Safari.
- 좁은 폭: 메뉴는 접히지 않고 메뉴 안에서 가로 스크롤(1440 825px, 1080 639px, 768·520 288px, 390 165px). 좁은 폭 디자인은 없습니다.
- 디자인 승인 대기(미적용): 속성Bar 차이 3건, 열림 상태 Header 상·하단 `#5F4B0F` 선, 폰트 차이.
- Task A의 기존 제한(넓은 `tools` + 390px에서 파일명·레이어 폭 0)은 이번 통합 범위 밖입니다.
+49
View File
@@ -0,0 +1,49 @@
# 홈 메뉴 Merge — `integration/sw-home-menu` → `feature/sw-package` (2026-10-19)
> 로컬 `git merge --no-commit --no-ff integration/sw-home-menu` (기준 `b434f27`, 대상 `1472e51`). **Merge Commit·Push는 하지 않았습니다(승인 대기).**
## 적용 범위 (사용자 정정 반영)
- 홈 메뉴(Figma 45개 노드, `SWHeader homeMenu` / `SWHomeMenu` / `SWRecentFiles`)는 **SW 일반 모드 전용**입니다.
- SW 뷰어 모드(`SWViewer*`)에는 홈 메뉴를 적용하지 않습니다. 뷰어 코드·Story는 변경하지 않았습니다(`sw-viewer` 소스에 홈 메뉴 참조 없음).
- AI 지침: AI_USAGE §0.2 표에 "로고(홈) 버튼 메뉴" 행(일반 = `homeMenu`, 뷰어 = 사용하지 않음), §0.3 혼합 금지 항목, 카탈로그 §5·DESIGN_RULES §12·PATTERNS 8의 "일반 모드 전용" 표기, VIEWER.md 잘못된 사용 행, 공통 AI_GUIDE 예시 B/B2.
## 충돌 해결
| 파일 | 해결 |
|---|---|
| `packages/sw/src/index.ts` | 두 Export 모두 유지(`sw-home-menu`, `sw-viewer`) |
| `ai/AI_USAGE.md` | 뷰어 모드 문장 유지 + 홈 메뉴 문장 추가 |
| `ai/COMPONENT_CATALOG.md` | 홈 메뉴 §5 추가, 이후 6 타입·7 비공개·8 제약, **뷰어 §8 → §9**(참조 갱신: AI_USAGE, VIEWER.md, DESIGN_RULES §11) |
| `ai/COMPOSITION_PATTERNS.md` | 뷰어 7 유지, **홈 메뉴 7 → 8**, 하지 말 것 8 → 9 |
| `ai/DESIGN_RULES.md` | 뷰어 §11 유지, **홈 메뉴 §11 → §12** |
`docs/sw/sw-home-menu-integration-2026-10-19.md`의 "패턴 7절, 디자인 규칙 11절"은 통합 당시 기록이라 그대로 둡니다(현재 번호는 위 표).
## Merge 중 추가 수정
| 파일 | 내용 | 이유 |
|---|---|---|
| `sw-header/SWHeader.tsx` | 홈 메뉴 시작 위치 = max(객체 수정 영역, `controls` 안 파일명 오른쪽) − 2px | `controls`로 파일명이 객체 수정 영역 안에 있는 Header(실제 SW 화면)에서 메뉴가 파일명을 덮음(x 63 → 378). 기본 Header 배치는 동일(캡처 바이트 동일) |
| `tests/ai-docs.check.mjs` | `` `SWViewer*` `` 같은 와일드카드 표기를 Export 이름으로 보지 않음 | Merge 전 뷰어 문서에서도 실패하던 오탐 |
| `tests/home-menu-integration.browser.mjs` | Story 목록 기대값에서 `SW/Tool` 제외, `SW/Viewer` 포함 | `SW/Tool`은 뷰어 작업(7f42923)에서 사용자 요청으로 삭제됨 |
## 검증
| 항목 | 결과 |
|---|---|
| SW typecheck / 앱 `tsc -b` / SW build / Storybook build | PASS / PASS / PASS / PASS |
| AI 문서(`check:ai-docs`, `ai-docs.check.mjs`) | PASS / 32/32 |
| 홈 메뉴 Storybook(`home-menu-integration.browser.mjs`, 배치 수정 후 재실행) | 22/22 |
| 기존 Header·Popup 기능(`integrated-improvement` options·popup) | 89/0 |
| 뷰어 Story 기능(`viewer.browser.mjs`, 참고 뷰어 없이) | 70/70 |
| Merge 전(b434f27) 대비 픽셀: Header·Popup 요소(`viewer-regression`) / SW Header·Popup·Viewer 전체 Story 48장(1440·768, threshold 0) | 39/39 / 48/48 동일 (배치 수정 전 측정, 수정은 메뉴가 열린 동안만 실행) |
| Figma 26:10591 | 현재 Figma 원본 = 저장 이미지(바이트 동일). 섹션 5·타일 15·크기 80×46·메뉴 1305×77·색·아이콘 일치. 글자는 Figma Dotum 14/12px vs HM Noto Sans KR(기존 "폰트 차이 · 디자인 승인 대기") |
| 실제 SW 화면(`D:\디자인시스템\HMDesignSwTest`, 0.2.0 로컬 tarball) | typecheck·build PASS, 브라우저 14/14(열기·다시 클릭 닫기·Hover `#FF5C00`·최근파일·Escape 순서·문서 Dropdown·속성 패널 토글). 콘솔 404 1건은 앱의 `/favicon.ico` 없음(기존) |
## NOT VERIFIED
- `SW/Tool` 섹션 회귀(Story 삭제됨), 참고 DWG/DXF 뷰어 대비 비교, Firefox·Safari, SW + HM UI CSS 동시 사용, Registry Publish.
- 배치 수정 후 Header·Popup·Viewer 픽셀 회귀 재실행(코드 경로상 메뉴가 닫혀 있으면 실행되지 않아 재사용).
- 실제 SW 화면의 미연결 타일 12개(저장·복사저장·내보내기·도면묶기·복구하기·임시파일·Display·저장하기·불러오기·설명보기·문의하기·S/W정보): 뷰어 앱에 해당 기능이 없어 실행 동작 없음(`toggle: false`, 툴팁 "뷰어에서 지원하지 않는 기능").
- 실제 SW 화면은 `logoSrc`를 주지 않아 로고 면이 비어 있음(기존 상태).
+2 -2
View File
@@ -212,7 +212,7 @@ import { Button, Card, CardContent, FilterBar, FilterBarActions, FilterBarSearch
### 예시 B1 — SW 일반 모드 (편집 Header + 도구 막대 + 설정 Popup)
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(일반 모드) → COMPOSITION_PATTERNS "6. 파일 열기 + 도구 막대", 예제 `ai/examples/ExampleF-ViewerToolbar.tsx`(파일 이름의 Viewer는 과거 명칭이며 일반 모드 `SWHeader` 예제)
- 재사용: `SWHeader`(`documentOpenPlacement="header"`, `controls`, `tools`), `SWToolGroup` + `SWToolButton`(Model/Layout 탭, Fit 등), `SWPopup` + `SWPopupSection` + `SWPopupRow` + `SWHeaderInput`(설정 Popup)
- 재사용: `SWHeader`(`documentOpenPlacement="header"`, `controls`, `tools`), `SWToolGroup` + `SWToolButton`(Model/Layout 탭, Fit 등), `SWPopup` + `SWPopupSection` + `SWPopupRow` + `SWHeaderInput`(설정 Popup), 로고(홈) 버튼 메뉴는 `SWHeader`의 `homeMenu` + `SWRecentFiles`(**일반 모드 전용**, 예제 `ExampleG-HomeMenu.tsx`)
- 신규: SW에 없는 기능(예: 검색) → 소비 프로젝트 컴포넌트, `<SWTheme>` 안, `--hm-sw-header-*` 토큰, 안쪽 입력은 `SWHeaderInput`(예제 `ExampleD-SearchExtension.tsx`)
```tsx
@@ -225,7 +225,7 @@ import "@hm-design/sw/styles.css"
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(뷰어 모드) → `VIEWER.md`, 예제 `ai/examples/ExampleH-ViewerMode.tsx`
- 재사용: `SWViewerHeader`(`groups` 데이터: `button`·`toggle`·`menu`·`tabs`·`switch`·`custom` 항목, `onCommand`, `trailing`에 `SWViewerStatus`·`SWViewerHeaderButton`), Dropdown은 `type: "menu"` 항목의 `entries`(단독·우클릭은 `SWViewerMenu`), 팝업은 `SWViewerPanel` + `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`
- 신규: 뷰어에 없는 패널 콘텐츠 → 소비 프로젝트 컴포넌트를 `SWViewerPanel`의 `children`에, 안쪽은 `SWViewerField` + `SWViewerInput` 등, 외형은 `--hm-sw-viewer-*` 토큰
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기, 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기, 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
+2 -2
View File
@@ -212,7 +212,7 @@ import { Button, Card, CardContent, FilterBar, FilterBarActions, FilterBarSearch
### 예시 B1 — SW 일반 모드 (편집 Header + 도구 막대 + 설정 Popup)
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(일반 모드) → COMPOSITION_PATTERNS "6. 파일 열기 + 도구 막대", 예제 `ai/examples/ExampleF-ViewerToolbar.tsx`(파일 이름의 Viewer는 과거 명칭이며 일반 모드 `SWHeader` 예제)
- 재사용: `SWHeader`(`documentOpenPlacement="header"`, `controls`, `tools`), `SWToolGroup` + `SWToolButton`(Model/Layout 탭, Fit 등), `SWPopup` + `SWPopupSection` + `SWPopupRow` + `SWHeaderInput`(설정 Popup)
- 재사용: `SWHeader`(`documentOpenPlacement="header"`, `controls`, `tools`), `SWToolGroup` + `SWToolButton`(Model/Layout 탭, Fit 등), `SWPopup` + `SWPopupSection` + `SWPopupRow` + `SWHeaderInput`(설정 Popup), 로고(홈) 버튼 메뉴는 `SWHeader`의 `homeMenu` + `SWRecentFiles`(**일반 모드 전용**, 예제 `ExampleG-HomeMenu.tsx`)
- 신규: SW에 없는 기능(예: 검색) → 소비 프로젝트 컴포넌트, `<SWTheme>` 안, `--hm-sw-header-*` 토큰, 안쪽 입력은 `SWHeaderInput`(예제 `ExampleD-SearchExtension.tsx`)
```tsx
@@ -225,7 +225,7 @@ import "@hm-design/sw/styles.css"
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(뷰어 모드) → `VIEWER.md`, 예제 `ai/examples/ExampleH-ViewerMode.tsx`
- 재사용: `SWViewerHeader`(`groups` 데이터: `button`·`toggle`·`menu`·`tabs`·`switch`·`custom` 항목, `onCommand`, `trailing`에 `SWViewerStatus`·`SWViewerHeaderButton`), Dropdown은 `type: "menu"` 항목의 `entries`(단독·우클릭은 `SWViewerMenu`), 팝업은 `SWViewerPanel` + `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`
- 신규: 뷰어에 없는 패널 콘텐츠 → 소비 프로젝트 컴포넌트를 `SWViewerPanel`의 `children`에, 안쪽은 `SWViewerField` + `SWViewerInput` 등, 외형은 `--hm-sw-viewer-*` 토큰
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기, 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기, 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
+2
View File
@@ -2,6 +2,8 @@
> **0.2.0 통합 개선(2026-10-19):** 패키지에 포함된 AI 안내 [ai/AI_USAGE.md](./ai/AI_USAGE.md)·[카탈로그](./ai/COMPONENT_CATALOG.md)가 공개 API의 기준입니다. 추가: `SWPopup` / `SWPopupSection` / `SWPopupRow`(속성창 외형 + `children` 자유 콘텐츠, 여러 Popup Escape 스택, Popup 안 Dropdown 고정 Overlay), `SWHeaderPropertyPanel`의 `children`·`title`·`autoFocus`와 선택형 `selection`/`properties`/`groups`, `SWToolButton` / `SWToolGroup`(기존 글자 토글·pill·파일명 면 재사용), `SWHeader`의 `documentOpenPlacement`(로고 오른쪽 Open, 실측 기반 More 이동)·`leading`, `hiddenControls`의 `documentNew`·`documentOpen`·`layerFreeze`·`layerLock`, `showNew`/`showOpen`/`showFreeze`/`showLock`. 모두 선택 Props이며 기존 화면은 그대로입니다. 빌드는 줄바꿈 정규화로 재현 가능하고 `dist/build-info.json`에 출처 Commit·도구 버전을 남깁니다. 검증 기록: 저장소 `docs/sw/sw-integrated-improvement-2026-10-19.md`.
> **로고 클릭 메뉴(2026-10-19):** `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`.
> **현재 기준 색인(2026-10-19 1차 정리):** 아래 날짜별 안내는 작성 당시 기록입니다. 현재 기준은 [사용 가이드](../../docs/sw/sw-header-usage.md), [디자인 명세](../../docs/sw/sw-header-design-spec.md) 상단 색인, 속성 필드 13개 [검증 기록](../../docs/sw/sw-property-fields-verification-2026-10-19.md), 게이지·속성Bar [후속 4건 기록](../../docs/sw/sw-header-followup-four-design-fixes-verification-2026-10-19.md)·[Firefox 게이지 기록](../../docs/sw/sw-header-firefox-range-close-verification-2026-10-19.md)입니다. Storybook은 `SW / Header` 33개 Story이며 전체 목록과 2차 검토 대상은 [1차 정리 보고](../../docs/sw/sw-first-pass-cleanup-review-2026-10-19.md)에 있습니다.
> **3차 구현(2026-10-16):** `hiddenControls`(로고 외 모든 구성요소 개별 제거, 로고 필수), `menuOrder`(레이어·색상·선/문자/치수스타일 순서), 속성창 `shapeContent` Slot, 속성창 4상태 개별·비교 Story를 추가했습니다. 미지정 시 기존 디자인과 같습니다. [사용법](../../docs/sw/sw-header-usage.md#구성요소-표시메뉴-순서속성창-4상태형상-slot-2026-10-16-3차), [3차 검증 기록](../../docs/sw/sw-header-composition-shape-verification-2026-10-16.md).
+2 -2
View File
@@ -212,7 +212,7 @@ import { Button, Card, CardContent, FilterBar, FilterBarActions, FilterBarSearch
### 예시 B1 — SW 일반 모드 (편집 Header + 도구 막대 + 설정 Popup)
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(일반 모드) → COMPOSITION_PATTERNS "6. 파일 열기 + 도구 막대", 예제 `ai/examples/ExampleF-ViewerToolbar.tsx`(파일 이름의 Viewer는 과거 명칭이며 일반 모드 `SWHeader` 예제)
- 재사용: `SWHeader`(`documentOpenPlacement="header"`, `controls`, `tools`), `SWToolGroup` + `SWToolButton`(Model/Layout 탭, Fit 등), `SWPopup` + `SWPopupSection` + `SWPopupRow` + `SWHeaderInput`(설정 Popup)
- 재사용: `SWHeader`(`documentOpenPlacement="header"`, `controls`, `tools`), `SWToolGroup` + `SWToolButton`(Model/Layout 탭, Fit 등), `SWPopup` + `SWPopupSection` + `SWPopupRow` + `SWHeaderInput`(설정 Popup), 로고(홈) 버튼 메뉴는 `SWHeader`의 `homeMenu` + `SWRecentFiles`(**일반 모드 전용**, 예제 `ExampleG-HomeMenu.tsx`)
- 신규: SW에 없는 기능(예: 검색) → 소비 프로젝트 컴포넌트, `<SWTheme>` 안, `--hm-sw-header-*` 토큰, 안쪽 입력은 `SWHeaderInput`(예제 `ExampleD-SearchExtension.tsx`)
```tsx
@@ -225,7 +225,7 @@ import "@hm-design/sw/styles.css"
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(뷰어 모드) → `VIEWER.md`, 예제 `ai/examples/ExampleH-ViewerMode.tsx`
- 재사용: `SWViewerHeader`(`groups` 데이터: `button`·`toggle`·`menu`·`tabs`·`switch`·`custom` 항목, `onCommand`, `trailing`에 `SWViewerStatus`·`SWViewerHeaderButton`), Dropdown은 `type: "menu"` 항목의 `entries`(단독·우클릭은 `SWViewerMenu`), 팝업은 `SWViewerPanel` + `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`
- 신규: 뷰어에 없는 패널 콘텐츠 → 소비 프로젝트 컴포넌트를 `SWViewerPanel`의 `children`에, 안쪽은 `SWViewerField` + `SWViewerInput` 등, 외형은 `--hm-sw-viewer-*` 토큰
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기, 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기, 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
+7 -5
View File
@@ -7,8 +7,8 @@
| 문서 | 내용 |
|---|---|
| [AI_GUIDE.md](./AI_GUIDE.md) | HMDesign 공통 진입점(모든 HMDesign 패키지에 같은 사본) |
| [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트(일반 모드: Header·개별 컨트롤·Popup·도구 막대 / 뷰어 모드 §8: `SWViewer*` 전체 Export)·Props·Callback·크기·CSS 의존성 |
| [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형·Popup 외형·도구 버튼(일반 모드 §1~10), 뷰어 토큰·상태값 요약(§11) |
| [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트(일반 모드: Header·개별 컨트롤·Popup·도구 막대·로고 클릭 메뉴 / 뷰어 모드 §9: `SWViewer*` 전체 Export)·Props·Callback·크기·CSS 의존성 |
| [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형·Popup 외형·도구 버튼·로고 클릭 메뉴(일반 모드 §1~10·§12), 뷰어 토큰·상태값 요약(§11) |
| [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header, Popup(자유 콘텐츠), 일반 모드 Header + 도구 막대(1~6), 뷰어 모드 기본 조합(7) |
| [examples/](./examples) | 공개 API만 쓰는 예제. **일반 모드** A~F(E = 사용자 정의 Popup, F = 파일 열기 + 도구 막대. 독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증. B·F 파일명의 "Viewer"는 과거 이름이며 일반 모드 `SWHeader` 구성), **뷰어 모드** H(`SWViewer*`) |
| [VIEWER.md](./VIEWER.md) | **뷰어 모드 전용**: `SWViewerHeader`(데이터 메뉴·More·외곽선·로고 규칙), `SWViewerMenu`(Dropdown·서브메뉴·우클릭), `SWViewerPanel`(팝업 프레임 + 자유 콘텐츠). 일반 SW 모드는 `SWHeader`·`SWPopup`을 그대로 씁니다 |
@@ -41,11 +41,12 @@ SW 패키지에는 Header·Popup 계열이 두 벌 있습니다. 화면마다 **
| 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` |
| 로고(홈) 버튼 메뉴 | `SWHeader`의 `homeMenu`(`SWHomeMenu` 데이터, 최근파일 `SWRecentFiles`, Figma 26:10591). 기본 숨김 → 로고 클릭 열기 → 다시 클릭 닫기 | **사용하지 않음.** `SWViewerHeader`에는 홈 메뉴 Props가 없고 로고 클릭은 `onHomeClick`만 호출합니다. 파일 열기·샘플은 Header `groups`의 `button`·`menu` 항목 |
| 버튼·탭·토글 | `SWToolButton` / `SWToolGroup` | Header 항목 `type: "button" \| "toggle" \| "tabs" \| "switch"`, 패널 안 `SWViewerButton` |
| 아이콘 | 패키지 자산(필드 안 아이콘) | 프로그램 아이콘 16px, 직접 그린 아이콘은 `SWViewerIcon`으로 감쌈 |
| 범위 | Header 밖 부품은 `<SWTheme>` 안 | `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`은 루트에 `hm-sw-theme`이 있어 `<SWTheme>` 불필요. 패널 컨트롤은 `SWViewerPanel` 안 |
| 토큰 | `--hm-sw-header-*`, Popup 콘텐츠 `--hm-sw-property-*` | `--hm-sw-viewer-*` + 공유 `--hm-sw-header-*`(아래 0.3) |
| 읽을 문서 | [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) §1~7, [DESIGN_RULES.md](./DESIGN_RULES.md) §1~10, [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) 1~6, 예제 A~F | [VIEWER.md](./VIEWER.md), [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) §8, [DESIGN_RULES.md](./DESIGN_RULES.md) §11, [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) 7, [예제 H](./examples/ExampleH-ViewerMode.tsx) |
| 읽을 문서 | [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) §1~8, [DESIGN_RULES.md](./DESIGN_RULES.md) §1~10·§12, [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) 1~6·8, 예제 A~G | [VIEWER.md](./VIEWER.md), [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) §9, [DESIGN_RULES.md](./DESIGN_RULES.md) §11, [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) 7, [예제 H](./examples/ExampleH-ViewerMode.tsx) |
- **일반 모드 Header에 도구 막대를 더한 구성**(로고 옆 파일 열기 + Model/Layout·Fit·2.5D 버튼을 `SWHeader`에 추가)은 **일반 모드**입니다: `SWHeader`의 `documentOpenPlacement`·`leading`·`tools` + `SWToolButton` / `SWToolGroup`(PATTERNS 6, 예제 F). "뷰어"·"도면 보기" 화면을 요청받았다면 이 구성이 아니라 뷰어 모드(`SWViewer*`)를 씁니다.
- 모드 진입·종료, 파일 처리, 도면 렌더링, 메뉴 명령 실행은 어느 모드든 **프로그램 담당**입니다.
@@ -55,6 +56,7 @@ SW 패키지에는 Header·Popup 계열이 두 벌 있습니다. 화면마다 **
- **한 화면에 Header 하나.** `SWHeader`와 `SWViewerHeader`를 같은 화면에 함께 두지 않습니다. 두 모드를 오가는 프로그램은 상태를 유지한 채 Header 컴포넌트만 바꿉니다(VIEWER.md §1).
- **뷰어 화면:** `SWHeader`에 뷰어 메뉴를 넣거나 `SWHeader`·`SWPopup`을 뷰어처럼 재스타일하지 않습니다. 뷰어 팝업은 `SWViewerPanel`입니다.
- **일반 화면:** `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`과 패널 컨트롤(`SWViewerSection` …)을 쓰지 않습니다.
- **로고(홈) 메뉴는 SW 일반 모드 전용:** `SWHomeMenu`·`SWHomeMenuSection`·`SWHomeMenuItem`·`SWRecentFiles`·`useSWHomeMenuClose`와 `SWHeader`의 `homeMenu`·`homeMenuOpen`·`defaultHomeMenuOpen`·`onHomeMenuOpenChange`는 일반 모드 `SWHeader` 화면에서만 씁니다. 뷰어 화면에는 홈 메뉴를 만들지 않고(`SWViewerHeader` 옆·`custom` 항목·`SWViewerPanel` 안에 넣거나 비슷하게 다시 만들지 않음), 뷰어 Header와 기능은 그대로 둡니다.
- **토큰:** `--hm-sw-viewer-*` 토큰을 일반 모드 신규 UI에 쓰지 않고, 일반 모드 전용 모양(속성창 행 `SWPopupRow`, `SWToolButton`의 `pill`·`text` 모양, 일반 필드 Dropdown)을 뷰어 신규 UI에 가져오지 않습니다. 두 토큰은 같은 범위(`.hm-sw-header`, `.hm-sw-theme`)에 함께 선언되어 있어 기술적으로 읽히지만, 읽힌다는 것이 허용을 뜻하지 않습니다.
- **공유 — 모드 혼합 아님(실제 코드가 공유하는 부분):**
- `SWViewerPanel`은 내부에서 `SWPopup` 프레임(제목 막대·제목·닫기·테두리·면)을 렌더링합니다. 뷰어에서 `SWViewerPanel`을 쓰는 것은 일반 모드 혼합이 아닙니다. 단, 뷰어 화면에서 `SWPopup`을 직접 쓰지는 않습니다.
@@ -76,8 +78,8 @@ SW 패키지에는 Header·Popup 계열이 두 벌 있습니다. 화면마다 **
## 1. 작업 순서 (필수)
1. **먼저 검색한다.** §0에서 모드를 정하고, 요청 기능을 [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md)의 공개 컴포넌트(뷰어 모드는 §8과 [VIEWER.md](./VIEWER.md))에서 찾는다.
2. **있으면 재사용한다(1순위).** 뷰어 모드는 `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`이다(VIEWER.md). 일반 모드에서 완성형 Header가 필요하면 `SWHeader` 하나만 쓴다. 기존 Props(`hiddenControls`, `menuOrder`, `controls`, `documentOpenPlacement`, `leading`, `tools`, 각 데이터 배열·Callback)로 해결되는지 먼저 본다. 팝업·설정창은 `SWPopup`, 버튼·탭·토글은 `SWToolButton` / `SWToolGroup`이다.
1. **먼저 검색한다.** §0에서 모드를 정하고, 요청 기능을 [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md)의 공개 컴포넌트(뷰어 모드는 §9와 [VIEWER.md](./VIEWER.md))에서 찾는다.
2. **있으면 재사용한다(1순위).** 뷰어 모드는 `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`이다(VIEWER.md). 일반 모드에서 완성형 Header가 필요하면 `SWHeader` 하나만 쓴다. 기존 Props(`hiddenControls`, `menuOrder`, `controls`, `documentOpenPlacement`, `leading`, `tools`, 각 데이터 배열·Callback)로 해결되는지 먼저 본다. 팝업·설정창은 `SWPopup`, 버튼·탭·토글은 `SWToolButton` / `SWToolGroup`, 일반 모드 로고 클릭 2단 메뉴는 `SWHeader`의 `homeMenu`(`SWHomeMenu` 데이터, 최근파일은 `SWRecentFiles`)이다. `SWToolButton`과 `SWHomeMenuItem`은 역할이 다르다(도구 막대 버튼 / 메뉴 타일). 서로 대신 쓰지 않는다.
- Popup 요청은 이렇게 판단한다: ① `SWPopup`이 프레임(외형·닫기·Escape·스크롤)을 제공한다 → ② 내용은 `children`으로 자유 구성(구조 제한 없음) → ③ 행 모양은 `SWPopupSection` / `SWPopupRow` + `SWHeaderInput` → ④ SW에 없는 컨트롤은 `@hm-design/ui`를 children에 → ⑤ 프레임을 다시 만들거나 없는 Props(`content`, `renderContent` 등)를 추측하지 않는다.
3. **없으면 조합한다(2순위).** 개별 공개 컴포넌트(`SWHeaderHome`, `SWHeaderDocumentTitle`, `SWHeaderLayerMenu` …)를 `<SWTheme>` 안에 배치한다. 배치용 CSS만 소비 프로젝트에 작성한다([COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md)).
4. **조합으로도 안 될 때만 새로 만든다(3순위).** 새 컴포넌트는 **소비 프로젝트**에 작성하고, 공개 컴포넌트(예: `SWHeaderInput`)를 내부에 재사용하며, 외형은 공개 토큰 `--hm-sw-header-*`만 읽는다([DESIGN_RULES.md](./DESIGN_RULES.md), 예제 D). 뷰어 모드의 새 UI는 `--hm-sw-viewer-*`(+ 공유 `--hm-sw-header-*` 면·외곽선, [DESIGN_RULES.md](./DESIGN_RULES.md) §11)를 읽고 Header 버튼·패널 버튼·입력은 `SWViewerHeaderButton`·`SWViewerButton`·`SWViewerField` + `SWViewerInput`을 재사용한다. 모드별 상세는 §0.4. 뷰어 화면에 일반 모드 컨트롤 모양을 섞지 않는다.
+32 -13
View File
@@ -7,9 +7,9 @@
- **열림 상태:** Dropdown은 `open` / `onOpenChange`(Controlled)를 주지 않으면 내부 상태로 동작합니다.
- **공통 키보드:** 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으로 정합니다. **뷰어 모드는 §8(`SWViewer*`)만** 쓰고, 아래 §1~4는 일반 모드용입니다. 두 모드를 한 화면에 섞지 않습니다.
- **무엇을 쓸까(일반 모드):** Header 전체 → `SWHeader`(1). Header 부품 조합 → 개별 컨트롤(2). 팝업·설정창·정보창 → `SWPopup`(3). 도구 막대·탭·토글 → `SWToolButton` / `SWToolGroup`(4). Slider·Switch·Badge·Progress·Dialog(모달)·Label·FilterBar는 SW에 없습니다 → `@hm-design/ui`를 그대로 씁니다(필요하면 `SWPopup` children 안에).
- **무엇을 쓸까(뷰어 모드):** Header → `SWViewerHeader`(`groups` + `trailing`), Dropdown·우클릭 → `SWViewerMenu`, 팝업·설정창·모달 → `SWViewerPanel` + `SWViewerSection`/`SWViewerField`/`SWViewerSwitch`/`SWViewerSlider`/`SWViewerInput`/`SWViewerButton`, 상태 → `SWViewerStatus`(8).
- **먼저 모드:** 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 → `SWViewerHeader`(`groups` + `trailing`), Dropdown·우클릭 → `SWViewerMenu`, 팝업·설정창·모달 → `SWViewerPanel` + `SWViewerSection`/`SWViewerField`/`SWViewerSwitch`/`SWViewerSlider`/`SWViewerInput`/`SWViewerButton`, 상태 → `SWViewerStatus`(9).
## 1. 완성형 템플릿
@@ -21,7 +21,8 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
| 필수 | `documentTitle: string` |
| 구성 | `hiddenControls?: SWHeaderHideableControlId[]`(로고 외 부품 제거, `home` 제거 시 정적 로고. 하위 항목 `documentNew`·`documentOpen`·`layerFreeze`·`layerLock`도 제거 — 레이아웃 변화 없음), `menuOrder?: SWHeaderMenuId[]`(5개 메뉴끼리 순서), `controls?: SWHeaderControlConfig[]`(객체 영역 전체 allowlist·순서·label) |
| 로고 오른쪽 | `documentOpenPlacement?: "menu" \| "header"`(기본 `menu` = 기존 Dropdown 하단. `header` = 로고 바로 오른쪽에 파일명과 같은 면의 Open 버튼, Dropdown의 Open은 숨김. 둘 다 `onDocumentOpen`·`documentOpenLabel`), `leading?: ReactNode`(Open 뒤·파일명 앞 추가 부품, 예: `SWToolButton appearance="document"`) |
| 로고 | `logo?: ReactNode`, `logoSrc?: string`, `logoAlt?: string`, `onHomeClick` |
| 로고 | `logo?: ReactNode`, `logoSrc?: string`, `logoAlt?: string`, `onHomeClick`(`homeMenu`가 없을 때만 호출) |
| 로고 클릭 메뉴 | `homeMenu?: SWHeaderHomeMenu`(= `SWHomeMenu` Props에서 열림 상태 제외, 5절), `homeMenuOpen`, `defaultHomeMenuOpen`(기본 false), `onHomeMenuOpenChange(open, reason: SWHeaderHomeMenuChangeReason)` |
| 파일 | `documents`, `activeDocumentId`, `documentMenuOpen`, `onDocumentMenuOpenChange`, `onDocumentSelect`, `onDocumentClose`, `onDocumentNew`, `onDocumentOpen`, `onDocumentClick`, `documentIcon`, `documentNewLabel`, `documentOpenLabel` |
| 레이어 | `layer`, `layers`, `activeLayerId`, `layerMenuOpen`, `onLayerMenuOpenChange`, `onLayerSelect`, `onLayerVisibilityChange`, `onLayerFreezeChange`, `onLayerLockChange`, `onLayerDetail`, `onLayerOpen` |
| 색상 | `color`, `colors`, `userColors`, `colorMode`, `colorMenuOpen`, `onColorMenuOpenChange`, `onColorSelect`, `onColorModeChange`, `onColorInput`, `onColorPick`, `onColorDetail`, `onColorOpen` |
@@ -34,7 +35,6 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
- 데이터 배열(`layers`, `colors`, `lineTypes`/`lineWeights`, `textStyles`, `dimensionStyles`, `documents`)을 주지 않으면 해당 필드는 Dropdown 없는 버튼입니다.
- 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`). 조합형 구성이 반응형이어야 하면 `controls` + `documentOpenPlacement` + `leading` + `tools`로 `SWHeader` 안에서 구성합니다(예제 F).
- 로고 옆 Open은 실제 공간을 측정해 넘칠 때만 More로 이동하고, 그 시점보다 Open 폭만큼 넓어지면 돌아옵니다. Open이 보이는 동안 파일명이 먼저 줄고 레이어 필드는 Figma 폭을 유지합니다.
```tsx
<SWHeader documentTitle={name} logoSrc={logo} logoAlt="제품명" layers={layers} activeLayerId={id} onLayerSelect={l => setId(l.id)} onClose={closeWindow} />
```
@@ -78,7 +78,7 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
## 4. 도구 막대 — 기존 SW 버튼 3종
**일반 모드** 도구 막대입니다. 새 디자인 값 없이 기존 SW 버튼 모양을 공개 버튼으로 씁니다(일반 모드 `SWHeader`에 더하는 Model/Layout, Fit, 2.5D, 파일 열기 등). 뷰어 모드 화면은 §8 `SWViewerHeader` 항목을 씁니다. `SWHeader`의 `tools`·`leading` 안이나 `<SWTheme>` 안에 둡니다.
**일반 모드** 도구 막대입니다. 새 디자인 값 없이 기존 SW 버튼 모양을 공개 버튼으로 씁니다(일반 모드 `SWHeader`에 더하는 Model/Layout, Fit, 2.5D, 파일 열기 등). 뷰어 모드 화면은 §9 `SWViewerHeader` 항목을 씁니다. `SWHeader`의 `tools`·`leading` 안이나 `<SWTheme>` 안에 둡니다.
| 컴포넌트 | 용도 | 주요 Props | 모양(기존 기준) | 검증 |
|---|---|---|---|---|
@@ -86,17 +86,36 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
| `SWToolGroup` | 버튼 묶음 | `appearance`(`text` 기본 = 상세보기 묶음, `pill` = 인쇄·K pill), `label`(`text`의 12px 캡션·접근성 이름), `selection`(`single` = radiogroup, 화살표·Home·End로 이동·선택), `div` 속성 | 버튼 사이 1px 구분선(`text`), 내용 폭 pill | ✅ |
- 탭(Model / Layout)은 별도 컴포넌트 없이 `SWToolGroup selection="single"` + `SWToolButton pressed`입니다.
- 일반 모드의 상태 표시(로드 상태 등) 전용 SW 디자인은 아직 없습니다(디자인 확정 후 검토). 뷰어 모드는 `SWViewerStatus`(§8)가 있으며 일반 모드 Header에 가져오지 않습니다.
- 일반 모드의 상태 표시(로드 상태 등) 전용 SW 디자인은 아직 없습니다(디자인 확정 후 검토). 뷰어 모드는 `SWViewerStatus`(§9)가 있으며 일반 모드 Header에 가져오지 않습니다.
## 5. 공개 타입
## 5. 로고 클릭 메뉴 — Figma 2단 Header 메뉴 (SW 일반 모드 전용)
`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`. 뷰어 모드 타입은 §8.
> **SW 일반 모드(`SWHeader`)에서만 씁니다.** SW 뷰어 모드(§9 `SWViewer*`)에는 홈 메뉴가 없으며 이 절의 컴포넌트를 뷰어 화면에 넣지 않습니다([AI_USAGE.md](./AI_USAGE.md) §0.3).
## 6. 공개하지 않은 것 (사용 금지)
Figma 26:10591(home on), 타일 15종 26:11355 … 26:11631, 섹션 제목 26:12604 … 26:12622, 최근파일 상태 662:67267 · 목록 662:67331 · 2뎁스 미리보기 662:67471을 **공통 컴포넌트 4개**로 표현합니다. 메뉴 항목·아이콘·제목·Active·이벤트·최근파일은 모두 데이터입니다(패키지에는 항목·아이콘이 없음). 운영체제 파일 선택창이 아니며, "찾아열기" 등의 실제 동작은 호스트가 `onSelect`에서 수행합니다.
`SWHeaderMore`, `SWHeaderField`/`SWHeaderFieldText`, `SWHeaderKGuide`, `useSWHeaderDropdown`, `SWHeaderMenuDetail`, `SWHeaderColorChip`, `SWHeaderLayerRows`, `SWHeaderColorPanelBody`, `SWHeaderLineStyleColumns`, `SWPopupFrame`, `useSWFixedDropdownPosition`, 속성창 내부 Picker, `useSWHeaderOverlay` 등은 내부 구현입니다. 필요하면 공개 컴포넌트로 대체하거나, 부족한 API로 보고합니다.
| 컴포넌트 | 용도 | 주요 Props | 크기·동작 | 검증 |
|---|---|---|---|---|
| `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`) | 높이 77(섹션 75 + 강조 2), 섹션 폭은 타일 수로 자동. 좁으면 메뉴 안 가로 스크롤 | ✅ |
| `SWHomeMenuSection` | 섹션(제목 badge + 타일 그룹) | `title`, `children` | badge 폭 = 글자 + 46px | ✅ |
| `SWHomeMenuItem` | 80 × 46 타일(Default / Hover / Active) | 데이터 `SWHomeMenuItemData`: `id`, `label`, `icon`, `hoverIcon`, `toggle`(true = 켜고 끄기, false = 실행만), `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 | ✅ |
## 7. 알려진 단독 사용 제약
- **Header 연결:** `SWHeader`의 `homeMenu`에 `SWHomeMenu` Props를 주면 됩니다. 기본 숨김, 로고 클릭 열기, 다시 클릭 닫기. 위치는 SWHeader가 실제 DOM으로 정합니다(객체 수정 영역 −2px ~ 창 영역, 고정 x 없음. `controls`에 `document`가 있으면 파일명 오른쪽 −2px부터). 로고·파일명·창 영역은 가리지 않습니다.
- **이벤트 우선순위:** `homeMenu`가 있으면 로고 클릭은 메뉴 열기·닫기만 하고 `onHomeClick`을 호출하지 않습니다(의도하지 않은 홈 이동 방지). 상태 알림은 `onHomeMenuOpenChange`(`reason`: `home`, `escape`, `background`, `outside`, `select`, `file`, `document`).
- **New / Open:** 기존 문서 Dropdown 안(`documentOpenPlacement="menu"` 기본) 그대로입니다. 메뉴의 "찾아열기"·"새파일"에 같은 동작이 필요하면 호스트가 `onDocumentOpen`·`onDocumentNew`와 같은 함수를 `onSelect`에 연결합니다. 메뉴가 열리면 필드 Dropdown·문서 Dropdown이 닫히고, 문서 Dropdown을 열면 메뉴가 닫힙니다(`reason: "document"`).
- **Escape·Focus:** 메뉴 안의 Escape는 열린 하위 패널 → 메뉴 순서로 하나씩 닫고, 메뉴가 닫히면 로고로 Focus를 돌립니다. 메뉴는 Popup Escape 순서(3절)에 함께 들어가므로 Focus가 메뉴·Popup 밖이면 가장 최근에 열거나 사용한 하나만 닫힙니다. Popup 안의 Escape는 그 Popup만 닫습니다.
- **하위 패널 닫기:** 패널 안 콘텐츠는 `useSWHomeMenuClose()`로 메뉴 전체를 닫을 수 있습니다(`SWRecentFiles`는 파일을 열면 자동).
## 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.
## 7. 공개하지 않은 것 (사용 금지)
`useSWPopupStack`, `SWHeaderMore`, `SWHeaderField`/`SWHeaderFieldText`, `SWHeaderKGuide`, `useSWHeaderDropdown`, `SWHeaderMenuDetail`, `SWHeaderColorChip`, `SWHeaderLayerRows`, `SWHeaderColorPanelBody`, `SWHeaderLineStyleColumns`, `SWPopupFrame`, `useSWFixedDropdownPosition`, 속성창 내부 Picker, `useSWHeaderOverlay` 등은 내부 구현입니다. 필요하면 공개 컴포넌트로 대체하거나, 부족한 API로 보고합니다.
## 8. 알려진 단독 사용 제약
| 항목 | 내용 | 대응 |
|---|---|---|
@@ -107,7 +126,7 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
| Dropdown 위치 | `SWHeader` 안이면 헤더, 밖이면 뷰포트 안으로 좌우 보정. `SWPopup`·속성창 안이면 고정 위치 Overlay | Popup 밖에서는 부모에 `overflow: hidden`을 두지 않는다 |
| 파일 열기 뒤 파일명 여백 | `SWToolButton appearance="document"` 바로 뒤의 파일명은 왼쪽 여백 12px(로고 아래가 아니므로) | 패키지 CSS가 처리. 소비 코드에서 조정하지 않음 |
## 8. 뷰어 모드 (`SWViewer*`)
## 9. 뷰어 모드 (`SWViewer*`)
SW **뷰어 모드** 전용 Export입니다(2026-10-19 추가, 참고 DWG/DXF 뷰어 실측 기준). 모드 선택은 [AI_USAGE.md](./AI_USAGE.md) §0, Props 상세·상태·금지 사항은 [VIEWER.md](./VIEWER.md), 토큰은 [DESIGN_RULES.md](./DESIGN_RULES.md) §11, 조합은 [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) 7과 [예제 H](./examples/ExampleH-ViewerMode.tsx)를 봅니다. 일반 모드 화면에서는 쓰지 않습니다. `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`은 루트에 `hm-sw-theme`을 가지므로 `<SWTheme>`가 필요 없습니다.
+28 -2
View File
@@ -4,7 +4,7 @@
## 0. 어떤 패턴을 고를까
먼저 SW 모드를 정합니다([AI_USAGE.md](./AI_USAGE.md) §0). 1~6은 **SW 일반 모드**, 7은 **SW 뷰어 모드**이며 한 화면에서 섞지 않습니다.
먼저 SW 모드를 정합니다([AI_USAGE.md](./AI_USAGE.md) §0). 1~6과 8은 **SW 일반 모드**, 7은 **SW 뷰어 모드**이며 한 화면에서 섞지 않습니다.
| 요청 | 모드 | 패턴 |
|---|---|---|
@@ -15,6 +15,7 @@
| SW Popup 디자인의 설정창·정보창·사용자 정의 폼 | 일반 | 5. Popup(외형 고정, 콘텐츠 자유) |
| 일반 모드 `SWHeader`에 로고 옆 파일 열기·도구 막대(Model/Layout, Fit, 2.5D) 추가 | 일반 | 6. 파일 열기 + 도구 막대 |
| 뷰어·DWG/DXF 보기·도면 뷰어·읽기 전용 화면(Header 메뉴, 패널, 모달, 불러오기 상태) | 뷰어 | 7. 뷰어 모드 기본 조합 |
| 일반 모드 `SWHeader`에서 로고를 누르면 열리는 2단 메뉴(저장·내보내기·찾아열기·최근파일 등) | 일반 | 8. 로고 클릭 메뉴 |
## 1. 완성형 SW Header — [ExampleA-DefaultHeader.tsx](./examples/ExampleA-DefaultHeader.tsx)
@@ -174,7 +175,32 @@ const groups: SWViewerHeaderGroup[] = [
- 상태(파일명·패널 열림·탭 값)는 프로그램이 가지고, 명령은 각 Callback 또는 `onCommand` 한 곳에서 받는다. 도면 영역 높이·렌더러 크기는 앱 레이아웃 담당(VIEWER.md §7).
- 없는 기능은 메뉴를 만들지 않거나 `disabled` + 미연결 보고. 패키지에 없는 UI는 [AI_USAGE.md](./AI_USAGE.md) §0.4 뷰어 열을 따른다.
## 8. 하지 말 것
## 8. 로고 클릭 메뉴 (일반 모드) — [ExampleG-HomeMenu.tsx](./examples/ExampleG-HomeMenu.tsx)
SW **일반 모드** `SWHeader` 전용입니다. 패턴 7(뷰어 모드)에는 홈 메뉴를 넣지 않습니다.
```tsx
const sections: SWHomeMenuSectionData[] = [
{ id: "working", title: "작업중인 파일", items: [
{ id: "save", label: "저장", icon: <SaveIcon />, toggle: false, onSelect: save },
] },
{ id: "open", title: "다른 파일 열기", items: [
{ id: "find-open", label: "찾아열기", icon: <OpenIcon />, toggle: false, onSelect: openFile },
{ id: "recent", label: "최근파일", icon: <RecentIcon />, panel: <SWRecentFiles files={recent} onFileOpen={openRecent} /> },
] },
]
<SWHeader documentTitle={name} documents={documents} onDocumentOpen={openFile} onDocumentNew={newFile}
homeMenu={{ sections, onItemSelect }} onHomeMenuOpenChange={(open, reason) => log(open, reason)} />
```
1. 카탈로그 §5의 데이터로 메뉴를 쓴다. 화면마다 타일·섹션·제목을 따로 만들지 않는다.
2. 메뉴 데이터 변경: 항목 추가·삭제는 `items` 배열, 숨김은 `hidden`, 비활성은 `disabled`, 실행만 하는 버튼은 `toggle: false`, 켜고 끄는 버튼은 기본값. Active를 직접 제어하려면 `homeMenu.activeIds` + `onActiveChange`.
3. 최근파일 패널은 선택 기능이다. 필요한 타일에만 `panel: <SWRecentFiles … />`를 준다. 파일을 열면 메뉴가 닫힌다(`closeOnOpen`).
4. 로고 클릭은 메뉴만 연다. `onHomeClick`의 홈 이동은 `homeMenu`가 있으면 실행되지 않는다. 메뉴 항목에 홈 이동이 필요하면 항목 `onSelect`에서 한다.
5. New / Open은 문서 Dropdown에 그대로 둔다(`documentOpenPlacement` 기본 `"menu"`). 로고 오른쪽에 별도 Open 버튼을 두지 않는다. 메뉴의 찾아열기·새파일은 같은 handler를 연결한다.
6. 위치·폭은 `SWHeader`가 정한다. 소비 코드에서 메뉴 위치를 계산하거나 겹치는 wrapper를 만들지 않는다.
## 9. 하지 말 것
- `hm-sw-*` class를 소비 코드에 쓰거나 CSS로 덮어쓰기
- `dist` 구현·`styles.css` 블록 복사로 같은 컴포넌트 재작성
+23 -2
View File
@@ -5,7 +5,7 @@
- 토큰은 `.hm-sw-header`와 `.hm-sw-theme`(`<SWTheme>`)에만 정의됩니다. 새 컴포넌트는 그 안에 렌더링합니다.
- SW는 `@hm-design/ui` 토큰을 쓰지 않습니다. UI 패키지 토큰을 복제하거나 섞지 않습니다.
- Light/Dark 분기 없음. 전역 선택자(`html`, `body`, `*`) 없음.
- **모드:** §1~10은 SW **일반 모드**(`SWHeader`·`SWPopup`·`SWToolButton`) 기준입니다. SW **뷰어 모드**(`SWViewer*`)의 토큰·상태는 §11과 [VIEWER.md](./VIEWER.md)를 봅니다. 모드 선택·혼합 금지는 [AI_USAGE.md](./AI_USAGE.md) §0.
- **모드:** §1~10과 §12는 SW **일반 모드**(`SWHeader`·`SWPopup`·`SWToolButton`·`SWHomeMenu`) 기준입니다. SW **뷰어 모드**(`SWViewer*`)의 토큰·상태는 §11과 [VIEWER.md](./VIEWER.md)를 봅니다. 모드 선택·혼합 금지는 [AI_USAGE.md](./AI_USAGE.md) §0.
## 1. 크기·간격
@@ -83,6 +83,8 @@
Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타일 → More, **< 1180** 그리기모드 숨김, **< 1080** 상세보기 → More·창 이동 24px(창 영역 176), **< 900** 색상 → More, **< 640** 인쇄·K → More·창 이동 숨김(창 영역 64), **< 520** 레이어 상태 아이콘 숨김. More에서 고른 필드는 첫 칸(레이어 자리)에 표시되고 Dropdown이 바로 열립니다(UI 상태만, 데이터 Callback 없음).
로고 클릭 메뉴는 좁은 폭에서 접히지 않고 메뉴 안에서 가로 스크롤합니다(좁은 폭 전용 디자인 없음, §12).
조합 Header(개별 컴포넌트)에는 이 규칙이 적용되지 않습니다. 필요한 컨트롤만 배치하고 폭 정책은 소비 앱이 정합니다.
## 8. Popup (속성창 프레임, `SWPopup` / `SWHeaderPropertyPanel`)
@@ -148,4 +150,23 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
| 상태 표시 | `SWViewerStatus`: 글자 `--hm-sw-viewer-muted` 11px 400, `error`는 `--hm-sw-viewer-accent`, `loading` 아이콘 회전 | — |
- **Typography(뷰어):** Header 항목 14px 500, 캡션·개수·상태 11px 400, 패널 필드 라벨 14px 500, Section 제목 12px 700, 패널 버튼·Badge 12px 500, Input 12px. Font Weight는 400/500/700만 씁니다.
- **뷰어 신규 컴포넌트 체크리스트:** ① VIEWER.md·§8 컴포넌트로 해결되는지 먼저 본다 ② 위 토큰을 `var(--…)`로만 읽고 일반 모드 전용 모양(§8 `SWPopupRow`, §9 `SWToolButton`)을 섞지 않는다 ③ 상태는 위 표대로 ④ 토큰에 없는 값은 결과 보고에 "AI 구성 값 — 디자인 확인 필요"로 적는다 ⑤ `hm-sw-viewer-*` class를 쓰거나 덮어쓰지 않는다.
- **뷰어 신규 컴포넌트 체크리스트:** ① VIEWER.md·카탈로그 §9 컴포넌트로 해결되는지 먼저 본다 ② 위 토큰을 `var(--…)`로만 읽고 일반 모드 전용 모양(§8 `SWPopupRow`, §9 `SWToolButton`)을 섞지 않는다 ③ 상태는 위 표대로 ④ 토큰에 없는 값은 결과 보고에 "AI 구성 값 — 디자인 확인 필요"로 적는다 ⑤ `hm-sw-viewer-*` class를 쓰거나 덮어쓰지 않는다.
## 12. 로고 클릭 메뉴 (`SWHeader homeMenu` / `SWHomeMenu`, Figma 26:10591) — SW 일반 모드 전용
이 절의 값은 일반 모드 `SWHeader`의 홈 메뉴에만 씁니다. 뷰어 모드(§11)에는 홈 메뉴가 없으며 `--hm-sw-home-menu-*`·`--hm-sw-recent-files-*` 토큰을 뷰어 신규 UI에 쓰지 않습니다.
| 항목 | 값 | 토큰 |
|---|---|---|
| 위치 | Header 맨 위(top 0), 객체 수정 영역 왼쪽 −2px ~ 창 영역 왼쪽(1920에서 x 375, 폭 1305). DOM 측정이며 고정 좌표 없음. `controls`로 파일명이 객체 수정 영역 안에 있으면 파일명 오른쪽 −2px부터 시작. 로고·파일명·창 영역은 가리지 않음. `z-index` 20(속성Bar 위, Dropdown 30·속성창 40 아래) | — |
| 크기 | 메뉴 높이 77(섹션 75 + 하단 강조 2), 그룹 46, 타일 80 × 46, 아이콘 18 슬롯, 섹션 폭은 타일 수로 자동 | — |
| 섹션 면 | 세로 gradient 면 + 1px `#081813` + 안쪽 1px `#20190E`, 아래 모서리 3px | `--hm-sw-home-menu-section-*` |
| 섹션 제목 | Figma tit_* badge(9-slice), 글자 400 검정 | `--hm-sw-home-menu-title-color` |
| 타일 상태 | Default 아이콘 `#B6B4AF` · Hover·Active 주황 gradient 1px 테두리 + 아이콘·글자 `#FF5C00`, 1px 위·왼쪽 이동. 라벨 700 | `--hm-sw-home-menu-item-*`, `--hm-sw-home-menu-icon-color` |
| 하단 강조 | `sections` = 섹션마다 주황 띠, `outline` = 메뉴 전체 2px 외곽선(하위 패널이 열린 동안, `auto` 기본) | `--hm-sw-home-menu-accent-*` |
| 최근파일 패널 | 폭 354, 행 50, 배경 `#27241D`, 주황 테두리, 행 Hover `#62533D` + `#FF5C00`, 미리보기 310 × 270 | `--hm-sw-recent-files-*` |
| 동작 | 기본 숨김 → 로고 클릭 열기 → 다시 클릭 닫기. 메뉴 빈 곳 클릭 닫기(Figma CLOSE). Escape: 하위 패널 → 메뉴, 닫히면 로고로 Focus. 메뉴 밖 클릭은 닫지 않음(Figma overlay 기준) | — |
| 좁은 폭 | 접는 디자인이 없어 메뉴 안 가로 스크롤(의도된 동작, 겹침·잘림 없음) | — |
- 열림 상태 Header 상·하단 `#5F4B0F` 2px 선(Figma home on)과 로고 열림 표시는 **적용하지 않습니다**(디자인 승인 대기). 임의로 추가하지 않습니다.
- 새 색·크기는 위 토큰만 `style`로 덮어씁니다. `hm-sw-home-menu*` class를 소비 코드에 쓰지 않습니다.
+2 -1
View File
@@ -2,7 +2,7 @@
기준: 2026-10-19, 참고 DWG/DXF 뷰어(`dwg-dxf-viewer-hm`) 상단 메뉴·팝업을 같은 해상도(1440 × 900)에서 실측해 패키지 컴포넌트로 옮긴 결과입니다. 아래 이름은 모두 `from "@hm-design/sw"` 공개 Export이고 CSS는 `@hm-design/sw/styles.css` 한 개입니다. 예제: [examples/ExampleH-ViewerMode.tsx](./examples/ExampleH-ViewerMode.tsx).
> **모드 선택 먼저:** 이 문서는 SW **뷰어 모드** 전용입니다. 화면이 뷰어 모드인지는 [AI_USAGE.md](./AI_USAGE.md) §0(설정 `HMDesign 사용 유형: SW 뷰어` → 요청 문구 → 불명확하면 질문)으로 정합니다. 전체 Export 목록은 [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) §8, 토큰·상태 요약은 [DESIGN_RULES.md](./DESIGN_RULES.md) §11, 조합은 [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) 7입니다.
> **모드 선택 먼저:** 이 문서는 SW **뷰어 모드** 전용입니다. 화면이 뷰어 모드인지는 [AI_USAGE.md](./AI_USAGE.md) §0(설정 `HMDesign 사용 유형: SW 뷰어` → 요청 문구 → 불명확하면 질문)으로 정합니다. 전체 Export 목록은 [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) §9, 토큰·상태 요약은 [DESIGN_RULES.md](./DESIGN_RULES.md) §11, 조합은 [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) 7입니다.
>
> **일반 모드 금지 사항(상호 참조):** 뷰어 화면에 `SWHeader`·`SWPopup`·`SWPopupSection`·`SWPopupRow`·`SWToolButton`·`SWToolGroup`·`SWHeaderPropertyPanel`을 쓰거나 재스타일하지 않고, 일반 모드 화면에 `SWViewer*`와 `--hm-sw-viewer-*` 토큰을 쓰지 않습니다. `SWViewerPanel`이 내부에서 `SWPopup` 프레임을 렌더링하는 것과 두 Header가 `--hm-sw-header-document-*`·`object-*`·`property-*`·외곽선 토큰을 공유하는 것은 패키지 구현이며 모드 혼합이 아닙니다([AI_USAGE.md](./AI_USAGE.md) §0.3). 패키지에 없는 뷰어 UI는 [AI_USAGE.md](./AI_USAGE.md) §0.4 뷰어 열을 따릅니다.
@@ -96,6 +96,7 @@ const groups: SWViewerHeaderGroup[] = [
| 하지 말 것 | 이유 / 대신 |
|---|---|
| `<SWViewerHeader logo="HM" />`처럼 로고가 없다고 임의 문자·아이콘 넣기 | 로고는 프로그램이 정합니다. 없으면 생략 |
| 뷰어 화면에 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`, `SWHeader homeMenu`) 넣기 또는 비슷한 2단 메뉴 직접 만들기 | 홈 메뉴는 SW 일반 모드 전용입니다. 뷰어 로고 클릭은 `onHomeClick`, 파일 열기·샘플은 `groups`의 `button`·`menu` 항목 |
| 뷰어 Header를 `div` + `button`으로 다시 만들기, `hm-sw-viewer-*` class를 소비 코드에 붙이기 | 데이터(`groups`)와 `custom` / `trailing`으로 구성 |
| Header 버튼 글자에 `-webkit-text-stroke`·`border`로 외곽선 만들기 | 외곽선은 패키지 토큰이 이미 적용합니다 |
| 메뉴 색·간격을 `#123456`, `margin` 등 임의 값으로 덮기 | 토큰 `--hm-sw-viewer-*` / `--hm-sw-header-*`만 `style`로 조정(기본값 유지 권장) |
@@ -0,0 +1,62 @@
// Example G — the logo (home) menu (Figma 26:10591): SWHeader `homeMenu` + sections data + the optional recent
// files panel. Hidden by default; a logo click opens it, a second click closes it. New / Open stay in the
// document dropdown. Every text, icon and action is the program's data; the package ships none.
import { useState } from "react"
import {
SWHeader, SWRecentFiles,
type SWFileEntry, type SWHomeMenuItemData, type SWHomeMenuSectionData,
} from "@hm-design/sw"
import { documents } from "./sample-data"
// One currentColor icon per tile follows the state colors (Default #B6B4AF, Hover / Active #FF5C00).
const Icon = ({ d }: { d: string }) => (
<svg viewBox="0 0 18 18" width="18" height="18" aria-hidden="true"><path d={d} fill="none" stroke="currentColor" strokeWidth="1.6" /></svg>
)
const recentFiles: SWFileEntry[] = [
{ id: "r1", name: "평면도_A.dwg", path: "D:\\Projects\\A" },
{ id: "r2", name: "종단면도_B.dwg", path: "D:\\Projects\\B" },
]
export function ExampleGHomeMenu({ onEvent }: { onEvent?: (name: string) => void }) {
const [activeDocumentId, setActiveDocumentId] = useState(documents[0].id)
const [extra, setExtra] = useState<SWHomeMenuItemData[]>([])
// The same handlers as the document dropdown: the menu adds entry points, never a second implementation.
const openFile = () => onEvent?.("document:open")
const newFile = () => onEvent?.("document:new")
const sections: SWHomeMenuSectionData[] = [
{ id: "working", title: "작업중인 파일", items: [
{ id: "save", label: "저장", icon: <Icon d="M3 3h10l2 2v10H3z M6 3v4h6V3" />, toggle: false, onSelect: () => onEvent?.("menu:save") },
{ id: "export", label: "내보내기", icon: <Icon d="M9 12V3 M5 7l4-4 4 4 M3 15h12" />, toggle: false, onSelect: () => onEvent?.("menu:export") },
] },
{ id: "open", title: "다른 파일 열기", items: [
{ id: "find-open", label: "찾아열기", icon: <Icon d="M3 5h5l2 2h5v8H3z" />, toggle: false, onSelect: openFile },
// Optional sub panel: the tile toggles it (Active = open), the menu shows the outline accent meanwhile.
{ id: "recent", label: "최근파일", icon: <Icon d="M9 4v5l3 2 M9 16A7 7 0 1 0 9 2a7 7 0 0 0 0 14" />,
panel: <SWRecentFiles files={recentFiles} onFileOpen={file => onEvent?.(`recent:${file.id}`)} />, panelLabel: "최근파일" },
{ id: "new-file", label: "새파일", icon: <Icon d="M4 2h7l3 3v11H4z M9 8v6 M6 11h6" />, toggle: false, onSelect: newFile },
...extra,
] },
]
return (
<>
<SWHeader
documentTitle={documents.find(item => item.id === activeDocumentId)?.name ?? ""}
logoAlt="예제 SW"
documents={documents}
activeDocumentId={activeDocumentId}
onDocumentSelect={document => { setActiveDocumentId(document.id); onEvent?.(`document:${document.id}`) }}
onDocumentNew={newFile}
onDocumentOpen={openFile}
homeMenu={{ sections, onItemSelect: event => onEvent?.(`menu:select:${event.item.id}:${event.active}`) }}
onHomeMenuOpenChange={(open, reason) => onEvent?.(`menu:${open ? "open" : "close"}:${reason}`)}
/>
{/* Adding an item is a data change only: width, dividers and corners follow the new item count. */}
<button type="button" style={{ marginTop: 40 }} onClick={() => setExtra(list => [...list, { id: `extra-${list.length + 1}`, label: `추가 ${list.length + 1}`, icon: <Icon d="M9 3v12 M3 9h12" /> }])}>
메뉴 항목 추가
</button>
</>
)
}
@@ -20,7 +20,8 @@ import { SWHeaderPropertyPanel } from "./components/SWHeaderPropertyPanel"
import { SWHeaderDimensionStyleMenu, SWHeaderTextStyleMenu } from "./components/SWHeaderStyleListMenus"
import { SWHeaderWindowControls } from "./components/SWHeaderWindowControls"
import { SWToolButton } from "../sw-tool/SWTool"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
import { SWHomeMenu } from "../sw-home-menu/SWHomeMenu"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderHomeMenuChangeReason, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
const defaults = {
layer: { name: "0" },
@@ -135,6 +136,10 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
onPropertyPanelOpenChange,
disabled = false,
tools,
homeMenu,
homeMenuOpen,
defaultHomeMenuOpen = false,
onHomeMenuOpenChange,
logo,
logoSrc,
logoAlt,
@@ -260,6 +265,41 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
setOpenCollapsed(false)
}
})
// Logo (home) menu: SWHeader owns its open state; a logo click toggles it instead of calling onHomeClick.
const homeMenuId = useId()
const homeButtonRef = useRef<HTMLElement | null>(null)
const [internalHomeOpen, setInternalHomeOpen] = useState(defaultHomeMenuOpen)
const homeOpen = !!homeMenu && shown("home") && (homeMenuOpen ?? internalHomeOpen)
const [internalDocumentOpen, setInternalDocumentOpen] = useState(false)
const documentOpenNow = documentMenuOpen ?? internalDocumentOpen
const setDocumentOpen = (next: boolean) => {
if (documentMenuOpen === undefined) setInternalDocumentOpen(next)
onDocumentMenuOpenChange?.(next)
}
const setHomeOpen = (next: boolean, reason: SWHeaderHomeMenuChangeReason) => {
if (homeMenuOpen === undefined) setInternalHomeOpen(next)
if (next !== homeOpen) onHomeMenuOpenChange?.(next, reason)
if (!next) return
// One header layer at a time: the menu covers the fields, so their dropdown and the document menu close.
if (openMenu) openers[openMenu].onOpenChange(false)
if (documentOpenNow) setDocumentOpen(false)
}
// Placement from the DOM (never a fixed x): 2px before the object area (Figma 375 vs 377 at 1920) up to the
// window area, so it follows the breakpoints, `leading`, the header Open and a narrowed file name.
const [homeBox, setHomeBox] = useState({ left: 0, width: 0 })
useLayoutEffect(() => {
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
homeButtonRef.current = header?.querySelector<HTMLElement>(":scope > .hm-sw-header__bar > .hm-sw-header__home") ?? null
if (!homeOpen || !header || !panelAnchorRef.current) return
const origin = header.getBoundingClientRect()
// With `controls`, the file name sits inside the object area: start after it so it stays visible.
const documentRight = fieldsRef.current?.querySelector<HTMLElement>(':scope > [data-control="document"]:not([data-hidden])')?.getBoundingClientRect().right
const start = Math.max(panelAnchorRef.current.getBoundingClientRect().left, documentRight ?? -Infinity)
const end = header.querySelector<HTMLElement>(":scope > .hm-sw-header__bar > .hm-sw-header__window")?.getBoundingClientRect().left ?? origin.right
const next = { left: start - origin.left - 2, width: end - start + 2 }
if (Math.abs(next.left - homeBox.left) > 0.1 || Math.abs(next.width - homeBox.width) > 0.1) setHomeBox(next)
})
const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot)
const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0]
const validPromoted = promoted && enabled(promoted) ? promoted : null
@@ -440,8 +480,9 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
title={documentTitle}
onClick={onDocumentClick}
documents={documents}
menuOpen={documentMenuOpen}
onMenuOpenChange={onDocumentMenuOpenChange}
// With a logo menu SWHeader holds the document menu state, so the two never stay open together.
menuOpen={homeMenu ? documentOpenNow : documentMenuOpen}
onMenuOpenChange={homeMenu ? next => { setDocumentOpen(next); if (next && homeOpen) setHomeOpen(false, "document") } : onDocumentMenuOpenChange}
onDocumentSelect={onDocumentSelect}
activeDocumentId={activeDocumentId}
onDocumentClose={onDocumentClose}
@@ -479,7 +520,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
return (
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} {...rest}>
<div className="hm-sw-header__bar">
<SWHeaderHome onClick={shown("home") ? onHomeClick : undefined} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt} />
<SWHeaderHome onClick={!shown("home") ? undefined : homeMenu ? () => setHomeOpen(!homeOpen, "home") : onHomeClick} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt}
aria-expanded={homeMenu && shown("home") ? homeOpen : undefined} aria-controls={homeOpen ? homeMenu?.id ?? homeMenuId : undefined} />
{hasLeading && <div ref={leadingRef} className="hm-sw-header__leading">
{openShown && <SWToolButton appearance="document" data-document-open="" disabled={disabled} onClick={onDocumentOpen}>{documentOpenLabel ?? "Open"}</SWToolButton>}
{leading}
@@ -505,6 +547,12 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
</div>}
</div>
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, maxWidth: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} /></div>}
{homeMenu && shown("home") && (
<div className="hm-sw-header__home-menu" style={{ left: homeBox.left, maxWidth: homeBox.width }}>
<SWHomeMenu {...homeMenu} id={homeMenu.id ?? homeMenuId} open={homeOpen} returnFocusRef={homeButtonRef}
onOpenChange={(next, reason) => setHomeOpen(next, reason)} />
</div>
)}
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} menus={{ showLayerFreeze: layerStates.showFreeze, showLayerLock: layerStates.showLock, ...propertyPanel.menus }} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
</header>
)
@@ -15,13 +15,16 @@ export interface SWHeaderHomeProps {
* SWHeader always shows the logo; only the home action can be removed. Default true.
*/
interactive?: boolean
/** Logo menu state (SWHeader `homeMenu`). No visual change: Figma has no open look for the logo. */
"aria-expanded"?: boolean
"aria-controls"?: string
}
/**
* Home button. The slanted background and the logo slot position are the common SW design;
* the logo itself belongs to each SW program, so no logo is rendered unless one is passed.
*/
export function SWHeaderHome({ onClick, logoSrc, logo, logoAlt, interactive = true }: SWHeaderHomeProps) {
export function SWHeaderHome({ onClick, logoSrc, logo, logoAlt, interactive = true, "aria-expanded": expanded, "aria-controls": controls }: SWHeaderHomeProps) {
const content = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
if (!interactive) return (
<div className="hm-sw-header__home" data-static="" role={logoAlt ? "img" : undefined} aria-label={logoAlt}>
@@ -30,7 +33,7 @@ export function SWHeaderHome({ onClick, logoSrc, logo, logoAlt, interactive = tr
</div>
)
return (
<button type="button" className="hm-sw-header__home" aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} onClick={onClick}>
<button type="button" className="hm-sw-header__home" aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} aria-expanded={expanded} aria-controls={controls} onClick={onClick}>
<img className="hm-sw-header__home-bg" src={homeBackground} alt="" aria-hidden="true" />
<img className="hm-sw-header__home-bg-hover" src={homeBackgroundHover} alt="" aria-hidden="true" />
{content && <span className="hm-sw-header__logo" aria-hidden="true">{content}</span>}
@@ -272,7 +272,8 @@ function onDocumentEscape(event: KeyboardEvent) {
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return
popupStack.at(-1)?.close()
}
function useSWPopupStack(onClose?: () => void) {
/** Internal (not exported by the package): also used by the open SWHomeMenu, which closes like a popup. */
export function useSWPopupStack(onClose?: () => void) {
const entry = useRef({ close: () => {} })
entry.current.close = () => onClose?.()
useEffect(() => {
@@ -31,3 +31,4 @@ export type {
export { SWHeaderPropertyBar, type SWHeaderPropertyBarProps } from "./components/SWHeaderPropertyBar"
export type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderSubControlId, SWHeaderHideableControlId } from "./types"
export type { SWHeaderHomeMenu, SWHeaderHomeMenuChangeReason } from "./types"
@@ -1,6 +1,7 @@
import type { HTMLAttributes, PointerEventHandler, ReactNode } from "react"
import type { SWHeaderPropertyBarProps } from "./components/SWHeaderPropertyBar"
import type { SWHeaderPropertyPanelProps } from "./components/SWHeaderPropertyPanel"
import type { SWHomeMenuCloseReason, SWHomeMenuProps } from "../sw-home-menu/types"
/** Header controls that can be shown as selected (text and arrow turn #FFC600). */
export type SWHeaderMenuId = "layer" | "color" | "lineStyle" | "textStyle" | "dimensionStyle"
@@ -146,6 +147,18 @@ export type SWHeaderFrameControlId = "home" | "windowMove" | "minimize" | "close
export type SWHeaderSubControlId = "documentNew" | "documentOpen" | "layerFreeze" | "layerLock"
/** Every Header part except the logo, which is always shown. */
export type SWHeaderHideableControlId = SWHeaderControlId | SWHeaderFrameControlId | SWHeaderSubControlId
/**
* Logo (home) menu of SWHeader: the SWHomeMenu props without the open state, which SWHeader owns
* (`homeMenuOpen` / `defaultHomeMenuOpen` / `onHomeMenuOpenChange`), and without the focus target (the logo).
*/
export type SWHeaderHomeMenu = Omit<SWHomeMenuProps, "open" | "defaultOpen" | "onOpenChange" | "returnFocusRef">
/**
* Why the logo menu opened or closed: the SWHomeMenu reasons, `home` (logo click / re-click) or `document`
* (the document dropdown opened, which closes the menu).
*/
export type SWHeaderHomeMenuChangeReason = SWHomeMenuCloseReason | "home" | "document"
export interface SWHeaderControlConfig {
id: SWHeaderControlId
visible?: boolean
@@ -323,6 +336,17 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
disabled?: boolean
/** Extra tools rendered inside the object area, after the menus. */
tools?: ReactNode
/**
* Logo (home) menu (Figma 26:10591): hidden by default, a logo click opens it and a second click closes it.
* It is placed from the measured header (object area − 2px up to the window area). While it is set, a logo
* click only toggles the menu and never calls `onHomeClick` (no unintended home navigation).
*/
homeMenu?: SWHeaderHomeMenu
/** Controlled open state of `homeMenu`. */
homeMenuOpen?: boolean
/** Uncontrolled initial state of `homeMenu`. Default false. */
defaultHomeMenuOpen?: boolean
onHomeMenuOpenChange?: (open: boolean, reason: SWHeaderHomeMenuChangeReason) => void
/**
* Logo image URL of the SW program, contained in the 32 x 17 logo slot.
* The logo is decided by each program: none is rendered when omitted.
@@ -333,6 +357,7 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
/** Accessible product name; the home button is announced as "<logoAlt> 홈" (default "홈"). */
logoAlt?: string
/** Logo click. Not called while `homeMenu` is set (the click toggles the menu instead). */
onHomeClick?: () => void
onDocumentClick?: () => void
onLayerOpen?: () => void
@@ -0,0 +1,341 @@
import {
createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState,
type ButtonHTMLAttributes, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode,
} from "react"
import { useSWPopupStack } from "../sw-header/components/SWHeaderPropertyPanel"
import titleBadge from "./assets/title-badge.svg"
// Imported where the component is used (not only in index.ts) so bundlers keep it with sideEffects: ["**/*.css"].
import "../../theme/sw-home-menu.css"
import type {
SWHomeMenuCloseReason, SWHomeMenuItemData, SWHomeMenuProps, SWHomeMenuSectionData, SWHomeMenuSelectEvent,
} from "./types"
interface WindowContextValue {
isActive: (id: string) => boolean
select: (item: SWHomeMenuItemData, section?: SWHomeMenuSectionData) => boolean
registerPanel: (id: string, panel: ReactNode | undefined, label: string) => void
panelId: string
close: (reason: SWHomeMenuCloseReason) => void
}
const WindowContext = createContext<WindowContextValue | null>(null)
const SectionContext = createContext<SWHomeMenuSectionData | undefined>(undefined)
/** For content inside a panel (e.g. SWRecentFiles): close the whole menu after opening a file. */
export function useSWHomeMenuClose() {
return useContext(WindowContext)?.close
}
const ITEM_SELECTOR = ".hm-sw-home-menu__item:not(:disabled)"
/**
* While open, the menu is on the SW popup Escape stack (SWPopup / property window): Escape with the focus outside
* every popup and the menu closes only the most recently used one (opened, focused or pressed last).
*/
function SWHomeMenuEscapeEntry({ onEscape, raiseRef }: { onEscape: () => void; raiseRef: { current: (() => void) | null } }) {
raiseRef.current = useSWPopupStack(onEscape)
return null
}
/**
* Figma 26:10591 "헤더/꾸러미 ver2/home on": the 2-tier header menu opened by the SW logo (home) button. Hidden by
* default; the host toggles `open` from SWHeader `onHomeClick` (logo click / re-click). Sections of 80 x 46 tiles;
* a tile may open an optional sub panel under the menu (Figma 최근파일 662:67331 → <SWRecentFiles>). Every text,
* icon, item and action is host data. Only the menu is drawn: the home button and window area stay those of
* <SWHeader>. Render it inside <SWTheme> (or next to SWHeader).
*/
export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function SWHomeMenu({
sections, children, open: openProp, defaultOpen = false, onOpenChange, activeIds: activeProp, defaultActiveIds,
onActiveChange, selection = "multiple", onItemSelect, closeOnBackgroundClick = true, closeOnSelect = false,
closeOnEscape = true, closeOnOutsideClick = false, autoFocus = true, returnFocusRef, accent = "auto",
className, style, onClick, onKeyDown, ...rest
}, ref) {
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const open = openProp ?? internalOpen
const [internalActive, setInternalActive] = useState<readonly string[]>(defaultActiveIds ?? [])
const activeIds = activeProp ?? internalActive
const rootRef = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => rootRef.current as HTMLDivElement)
const previousFocus = useRef<HTMLElement | null>(null)
const panelId = useId()
// Panels of the rendered tiles (id → content); the open one is the Active tile that has a panel.
const [panels, setPanels] = useState<Record<string, { panel: ReactNode; label: string }>>({})
const registerPanel = useCallback((id: string, panel: ReactNode | undefined, label: string) => {
setPanels(current => {
if (panel == null) {
if (!(id in current)) return current
const { [id]: _removed, ...next } = current
return next
}
if (current[id]?.panel === panel && current[id]?.label === label) return current
return { ...current, [id]: { panel, label } }
})
}, [])
const openPanelId = activeIds.find(id => id in panels)
const latest = useRef({ activeIds, onActiveChange, onItemSelect, selection, closeOnSelect, panels })
latest.current = { activeIds, onActiveChange, onItemSelect, selection, closeOnSelect, panels }
const setActive = useCallback((next: string[], event: SWHomeMenuSelectEvent) => {
if (activeProp === undefined) setInternalActive(next)
latest.current.onActiveChange?.(next, event)
}, [activeProp])
const close = useCallback((reason: SWHomeMenuCloseReason) => {
const root = rootRef.current
// Focus inside the menu would be lost when it unmounts: return it to the trigger / previous element.
if (root && root.contains(document.activeElement)) (returnFocusRef?.current ?? previousFocus.current)?.focus()
// A sub panel closes with the menu: its tile returns to Default (the next open starts without it).
const { activeIds: current, panels: known } = latest.current
const openPanel = current.find(id => id in known)
if (openPanel) setActive(current.filter(id => id !== openPanel), { item: { id: openPanel, label: known[openPanel].label }, active: false })
if (openProp === undefined) setInternalOpen(false)
onOpenChange?.(false, reason)
}, [openProp, onOpenChange, returnFocusRef, setActive])
// Opening (not the first render): remember the focus to return to, then focus the first tile.
const wasOpen = useRef(open)
useLayoutEffect(() => {
if (open && !wasOpen.current) {
const active = document.activeElement
previousFocus.current = active instanceof HTMLElement && !rootRef.current?.contains(active) ? active : null
if (autoFocus) rootRef.current?.querySelector<HTMLElement>(ITEM_SELECTOR)?.focus()
}
wasOpen.current = open
}, [open, autoFocus])
useEffect(() => {
if (!open || !closeOnOutsideClick) return
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (rootRef.current?.contains(target) || returnFocusRef?.current?.contains(target)) return
close("outside")
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
}, [open, closeOnOutsideClick, close, returnFocusRef])
// Panel position: under the menu, 1px right of its tile's left edge (Figma 최근파일 808 vs tile 807).
const [panelLeft, setPanelLeft] = useState(0)
useLayoutEffect(() => {
if (!openPanelId || !rootRef.current) return
const root = rootRef.current
const measure = () => {
const tile = root.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`)
if (tile) setPanelLeft(tile.getBoundingClientRect().left - root.getBoundingClientRect().left + 1)
}
measure()
const scroller = root.querySelector(".hm-sw-home-menu__sections")
scroller?.addEventListener("scroll", measure)
const observer = new ResizeObserver(measure)
observer.observe(root)
return () => { scroller?.removeEventListener("scroll", measure); observer.disconnect() }
}, [openPanelId])
const context = useMemo<WindowContextValue>(() => ({
isActive: id => activeIds.includes(id),
registerPanel,
panelId,
close,
select: (item, section) => {
const { activeIds: current, onItemSelect: notify, selection: mode, closeOnSelect: closeAfter, panels: known } = latest.current
const toggle = item.toggle !== false
const active = toggle && !current.includes(item.id)
const event: SWHomeMenuSelectEvent = { item, section, active }
if (toggle) {
let next = active ? (mode === "single" ? [item.id] : [...current, item.id]) : current.filter(id => id !== item.id)
// One sub panel at a time: opening a panel tile closes the other panel tile.
if (active && item.panel != null) next = next.filter(id => id === item.id || !(id in known))
setActive(next, event)
}
item.onSelect?.(event)
notify?.(event)
if (closeAfter && item.panel == null) close("select")
return active
},
}), [activeIds, registerPanel, panelId, close, setActive])
const raiseRef = useRef<(() => void) | null>(null)
if (!open) return null
/** Escape: an open sub panel closes first (focus back on its tile when it was inside), then the menu. */
const escape = () => {
if (openPanelId) {
const item = { id: openPanelId, label: panels[openPanelId]?.label ?? openPanelId }
const focusInside = rootRef.current?.contains(document.activeElement)
setActive(activeIds.filter(id => id !== openPanelId), { item, active: false })
if (focusInside) rootRef.current?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`)?.focus()
return
}
close("escape")
}
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
onClick?.(event)
if (event.defaultPrevented || !closeOnBackgroundClick) return
const target = event.target as Element
if (!target.closest("button, a, input, [role='listbox'], .hm-sw-home-menu__panel")) close("background")
}
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
if (event.key === "Escape" && closeOnEscape) {
// preventDefault: the document-level popup Escape handler ignores it (one Escape closes one layer).
event.preventDefault()
escape()
return
}
// ArrowDown on the tile of the open sub panel moves into the panel (keyboard access, not in Figma).
if (event.key === "ArrowDown" && openPanelId && (document.activeElement as HTMLElement | null)?.dataset.itemId === openPanelId) {
const first = rootRef.current?.querySelector<HTMLElement>(".hm-sw-home-menu__panel :is(button, [href], input, [tabindex]):not(:disabled, [tabindex='-1'])")
if (first) { event.preventDefault(); first.focus() }
return
}
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return
const items = Array.from(rootRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
const index = items.indexOf(document.activeElement as HTMLElement)
if (index < 0) return
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
: (index + (event.key === "ArrowRight" ? 1 : -1) + items.length) % items.length
items[next].focus()
}
const resolvedAccent = accent === "auto" ? (openPanelId ? "outline" : "sections") : accent
const openPanel = openPanelId ? panels[openPanelId] : undefined
return (
<WindowContext.Provider value={context}>
<div
role="region"
aria-label={rest["aria-label"] ?? "홈 메뉴"}
{...rest}
ref={rootRef}
className={`hm-sw-home-menu${className ? ` ${className}` : ""}`}
data-accent={resolvedAccent}
data-panel-open={openPanel ? "true" : undefined}
style={{ "--hm-sw-home-menu-title-art": `url("${titleBadge}")`, ...style } as CSSProperties}
onClick={handleClick}
onKeyDown={handleKeyDown}
onFocusCapture={event => { raiseRef.current?.(); rest.onFocusCapture?.(event) }}
onPointerDownCapture={event => { raiseRef.current?.(); rest.onPointerDownCapture?.(event) }}
>
{closeOnEscape && <SWHomeMenuEscapeEntry onEscape={escape} raiseRef={raiseRef} />}
<div className="hm-sw-home-menu__sections">
{sections
? sections.filter(section => !section.hidden).map(section => (
<SWHomeMenuSection key={section.id} section={section} title={section.title}>
{section.items.filter(item => !item.hidden).map(item => <SWHomeMenuItem key={item.id} item={item} />)}
</SWHomeMenuSection>
))
: children}
</div>
{openPanel && (
<div className="hm-sw-home-menu__panel" id={panelId} role="group" aria-label={openPanel.label} style={{ left: panelLeft }}>
{openPanel.panel}
</div>
)}
</div>
</WindowContext.Provider>
)
})
export interface SWHomeMenuSectionProps {
title: string
children: ReactNode
/** Data record passed back in select events (set automatically in data mode). */
section?: SWHomeMenuSectionData
className?: string
}
/** Figma section (e.g. Frame 2408): title badge + tile group. Its width follows the number of tiles. */
export function SWHomeMenuSection({ title, children, section, className }: SWHomeMenuSectionProps) {
const titleId = useId()
return (
<SectionContext.Provider value={section}>
<div className={`hm-sw-home-menu__section${className ? ` ${className}` : ""}`} role="group" aria-labelledby={titleId}>
<span className="hm-sw-home-menu__title" id={titleId}>{title}</span>
<div className="hm-sw-home-menu__group">{children}</div>
</div>
</SectionContext.Provider>
)
}
export interface SWHomeMenuItemProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "children"> {
/** Data mode: the whole record. Otherwise give `itemId` + `label` (+ `icon`, `panel`, …). */
item?: SWHomeMenuItemData
itemId?: string
label?: string
icon?: ReactNode
hoverIcon?: ReactNode
toggle?: boolean
panel?: ReactNode
panelLabel?: string
/** Outside <SWHomeMenu> only: controlled / initial Active and its change. Inside, the menu owns Active. */
active?: boolean
defaultActive?: boolean
onActiveChange?: (active: boolean) => void
onSelect?: (event: SWHomeMenuSelectEvent) => void
}
/** Figma tile 80 x 46 with Default / Variant2 (Hover) / Variant3 (Active, same look as Variant2). */
export function SWHomeMenuItem({
item: itemProp, itemId, label, icon, hoverIcon, toggle, panel, panelLabel, active: activeProp, defaultActive = false,
onActiveChange, onSelect, disabled, title, className, onClick, ...rest
}: SWHomeMenuItemProps) {
const host = useContext(WindowContext)
const section = useContext(SectionContext)
const generatedId = useId()
const item: SWHomeMenuItemData = itemProp ?? {
id: itemId ?? generatedId, label: label ?? "", icon, hoverIcon, toggle, panel, panelLabel, disabled, title, onSelect,
"aria-label": rest["aria-label"],
}
const [ownActive, setOwnActive] = useState(defaultActive)
const toggles = item.toggle !== false
const active = toggles && (host ? host.isActive(item.id) : activeProp ?? ownActive)
const isDisabled = item.disabled ?? disabled
const hasPanel = host != null && item.panel != null
const register = host?.registerPanel
useLayoutEffect(() => {
if (!register) return
register(item.id, item.panel ?? undefined, item.panelLabel ?? item.label)
return () => register(item.id, undefined, "")
}, [register, item.id, item.panel, item.panelLabel, item.label])
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event)
if (event.defaultPrevented) return
if (host) {
host.select(item, section)
return
}
const next = toggles && !active
if (toggles) {
if (activeProp === undefined) setOwnActive(next)
onActiveChange?.(next)
}
item.onSelect?.({ item, active: next })
}
return (
<button
type="button"
{...rest}
className={`hm-sw-home-menu__item${className ? ` ${className}` : ""}`}
data-item-id={item.id}
data-active={active ? "true" : undefined}
data-has-hover-icon={item.hoverIcon ? "" : undefined}
aria-pressed={toggles && !hasPanel ? active : undefined}
aria-expanded={hasPanel ? active : undefined}
aria-controls={hasPanel && active ? host.panelId : undefined}
aria-label={item["aria-label"]}
title={item.title ?? item.label}
disabled={isDisabled}
onClick={handleClick}
>
{item.icon != null && <span className="hm-sw-home-menu__icon" aria-hidden="true">{item.icon}</span>}
{item.hoverIcon != null && <span className="hm-sw-home-menu__icon hm-sw-home-menu__icon--hover" aria-hidden="true">{item.hoverIcon}</span>}
<span className="hm-sw-home-menu__label">{item.label}</span>
</button>
)
}
@@ -0,0 +1,92 @@
import { useId, useRef, useState, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import "../../theme/sw-home-menu.css"
import { useSWHomeMenuClose } from "./SWHomeMenu"
import type { SWFileEntry } from "./types"
export interface SWRecentFilesProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
/** Host-owned list (most recent first). Nothing is read from disk here. */
files: readonly SWFileEntry[]
/** A row was chosen (click / Enter). The host opens the file. */
onFileOpen?: (file: SWFileEntry) => void
/** Hover / focus change of the previewed row (null when none). */
onFileHover?: (file: SWFileEntry | null) => void
/** Preview content when a file has no `preview` (e.g. a thumbnail loaded by the host). */
renderPreview?: (file: SWFileEntry) => ReactNode
/** Inside <SWHomeMenu>: close the menu after `onFileOpen`. Default true. */
closeOnOpen?: boolean
/** Shown when `files` is empty (not in Figma). */
emptyText?: ReactNode
"aria-label"?: string
}
/**
* 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
* capped (`--hm-sw-recent-files-max-height`, Figma 750 = 15 rows); longer lists scroll inside.
*/
export function SWRecentFiles({
files, onFileOpen, onFileHover, renderPreview, closeOnOpen = true, emptyText = "최근 파일이 없습니다",
className, onKeyDown, ...rest
}: SWRecentFilesProps) {
const closeWindow = useSWHomeMenuClose()
const listId = useId()
const listRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState<{ id: string; top: number } | null>(null)
const current = hovered ? files.find(file => file.id === hovered.id) : undefined
const preview = current ? current.preview ?? renderPreview?.(current) : undefined
const show = (file: SWFileEntry | null, row?: HTMLElement) => {
const list = listRef.current
const top = row && list ? row.offsetTop - list.scrollTop + 1 : 0
setHovered(file ? { id: file.id, top } : null)
if ((file?.id ?? null) !== (hovered?.id ?? null)) onFileHover?.(file)
}
const open = (file: SWFileEntry) => {
if (file.disabled) return
onFileOpen?.(file)
if (closeOnOpen) closeWindow?.("file")
}
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return
const rows = Array.from(listRef.current?.querySelectorAll<HTMLElement>(".hm-sw-recent-files__row:not(:disabled)") ?? [])
const index = rows.indexOf(document.activeElement as HTMLElement)
event.preventDefault()
event.stopPropagation()
const next = event.key === "Home" ? 0 : event.key === "End" ? rows.length - 1
: index < 0 ? 0 : Math.min(rows.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))
rows[next]?.focus()
}
return (
<div {...rest} className={`hm-sw-recent-files${className ? ` ${className}` : ""}`} onKeyDown={handleKeyDown}
onMouseLeave={() => show(null)}>
<div ref={listRef} className="hm-sw-recent-files__list" id={listId} role="list" aria-label={rest["aria-label"] ?? "최근 파일"}
onScroll={() => hovered && setHovered(null)}>
{files.length === 0 && <div className="hm-sw-recent-files__empty" role="listitem">{emptyText}</div>}
{files.map(file => (
<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}
data-hovered={hovered?.id === file.id ? "true" : undefined} disabled={file.disabled}
title={file.title ?? (file.path ? `${file.name}\n${file.path}` : file.name)}
onMouseEnter={event => show(file, event.currentTarget)} onFocus={event => show(file, event.currentTarget)}
onClick={() => open(file)}>
{file.badge != null && <span className="hm-sw-recent-files__badge" aria-hidden="true">{file.badge}</span>}
<span className="hm-sw-recent-files__text">
<span className="hm-sw-recent-files__name">{file.name}</span>
{file.path && <span className="hm-sw-recent-files__path">{file.path}</span>}
</span>
</button>
</div>
))}
</div>
{current && preview != null && (
<div className="hm-sw-recent-files__preview" style={{ top: hovered?.top }} aria-hidden="true">
<span className="hm-sw-recent-files__preview-title">{current.previewTitle ?? current.path ?? current.name}</span>
<div className="hm-sw-recent-files__preview-body">{preview}</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,31 @@
<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">
<g id="Union" filter="url(#filter0_d_0_457)">
<use xlink:href="#stroke0_0_457" fill="url(#paint0_linear_0_457)"/>
<mask id="stroke1_0_457" fill="white">
<use xlink:href="#stroke0_0_457"/>
</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)"/>
</g>
<defs>
<filter id="filter0_d_0_457" x="0" y="0" width="132" height="27" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-2"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.4 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_457"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_0_457" result="shape"/>
</filter>
<linearGradient id="paint0_linear_0_457" x1="66.3862" y1="6" x2="66.3862" y2="25" gradientUnits="userSpaceOnUse">
<stop stop-color="#887757"/>
<stop offset="0.755507" stop-color="#A8997C"/>
<stop offset="1" stop-color="#C3B79E"/>
</linearGradient>
<linearGradient id="paint1_linear_0_457" x1="66.3862" y1="6" x2="66.3862" y2="28" gradientUnits="userSpaceOnUse">
<stop stop-color="#7B6332"/>
<stop offset="1" stop-color="#9F8D68"/>
</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"/>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,9 @@
export {
SWHomeMenu, SWHomeMenuSection, SWHomeMenuItem, useSWHomeMenuClose,
type SWHomeMenuSectionProps, type SWHomeMenuItemProps,
} from "./SWHomeMenu"
export { SWRecentFiles, type SWRecentFilesProps } from "./SWRecentFiles"
export type {
SWHomeMenuProps, SWHomeMenuItemData, SWHomeMenuSectionData, SWHomeMenuSelectEvent, SWHomeMenuCloseReason, SWHomeMenuAccent,
SWFileEntry,
} from "./types"
@@ -0,0 +1,108 @@
import type { HTMLAttributes, ReactNode, RefObject } from "react"
/** One 80 x 46 tile of the logo (home) menu (Figma 26:11355 … 26:11631). Text, icons and actions belong to the host. */
export interface SWHomeMenuItemData {
id: string
/** One line. Longer text ends with an ellipsis and the full text is shown as `title`. */
label: string
/**
* Icon in an 18 x 18 slot (centered; larger art may overflow it like the Figma assets). A `currentColor`
* icon follows the state color (Default #B6B4AF, Hover / Active #FF5C00); an image needs `hoverIcon`.
*/
icon?: ReactNode
/** Replaces `icon` on Hover and Active, for fixed-color image pairs (Figma default / hover SVG). Same name as SWToolButton. */
hoverIcon?: ReactNode
/**
* true (default): a click toggles Active (Figma Default → Variant2 on hover → Variant3 on click → Default).
* false: a plain action button without Active state.
*/
toggle?: boolean
/**
* Optional sub panel opened under the tile while the tile is Active, e.g. <SWRecentFiles> (Figma 최근파일 662:67331).
* Only one tile with a panel is Active at a time; a second click closes it.
*/
panel?: ReactNode
/** Accessible name of the panel (defaults to the label). */
panelLabel?: string
disabled?: boolean
/** Not rendered; the remaining tiles close the gap (no empty slot). */
hidden?: boolean
/** Tooltip; defaults to `label`. */
title?: string
"aria-label"?: string
onSelect?: (event: SWHomeMenuSelectEvent) => void
}
export interface SWHomeMenuSectionData {
id: string
/** Section badge text (Figma tit_* 26:12604 …). The badge width follows the text. */
title: string
items: readonly SWHomeMenuItemData[]
hidden?: boolean
}
export interface SWHomeMenuSelectEvent {
item: SWHomeMenuItemData
section?: SWHomeMenuSectionData
/** Active state after this click (always false for `toggle: false` items). */
active: boolean
}
export type SWHomeMenuCloseReason = "escape" | "background" | "outside" | "select" | "file"
/**
* Bottom accent under the sections. `sections` = Figma 26:10591 (one orange strip per section);
* `outline` = Figma 662:67267 (2px orange outline around the whole menu, shown with an open sub panel).
* Default `auto`: `outline` while a panel is open, otherwise `sections`.
*/
export type SWHomeMenuAccent = "auto" | "sections" | "outline"
export interface SWHomeMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect" | "title" | "children"> {
/** Data mode. Use either `sections` or `children` (<SWHomeMenuSection>). */
sections?: readonly SWHomeMenuSectionData[]
children?: ReactNode
/** Controlled open state (e.g. toggled by SWHeader `onHomeClick`, the logo). Closed renders nothing. */
open?: boolean
/** Uncontrolled initial state. Default false: the menu is hidden until the logo is clicked. */
defaultOpen?: boolean
onOpenChange?: (open: boolean, reason: SWHomeMenuCloseReason) => void
/** Controlled Active (Figma Variant3) tile ids. */
activeIds?: readonly string[]
defaultActiveIds?: readonly string[]
onActiveChange?: (activeIds: string[], event: SWHomeMenuSelectEvent) => void
/** `multiple` (default, each tile toggles on its own as in Figma) or `single` (one Active tile at a time). */
selection?: "multiple" | "single"
/** Every tile click, after the item's own `onSelect`. */
onItemSelect?: (event: SWHomeMenuSelectEvent) => void
/** Figma: a click on the menu outside a tile closes it (Group 2882 ON_CLICK → CLOSE). Default true. */
closeOnBackgroundClick?: boolean
/** Close after a tile click. Default false (Figma: a tile click changes the tile to Variant3). */
closeOnSelect?: boolean
/** Not in Figma (keyboard access). Escape closes an open panel first, then the menu. Default true. */
closeOnEscape?: boolean
/** Not in Figma (its overlay has no outside interaction). Default false. */
closeOnOutsideClick?: boolean
/** Focus the first tile when the menu opens. Default true (keyboard access). */
autoFocus?: boolean
/** Focus target after Escape. Default: the element that had focus when the menu opened. */
returnFocusRef?: RefObject<HTMLElement | null>
accent?: SWHomeMenuAccent
"aria-label"?: string
}
/** One row of <SWRecentFiles> (Figma header/최근파일/list, 354 x 50). */
export interface SWFileEntry {
id: string
/** File name (14px, one line, ellipsis). */
name: string
/** Location (12px, 60%, one line, ellipsis). */
path?: string
/** 29 x 28 slot before the text (Figma icon_dualmonitor: the file is open on monitor 1 / 2). Rows without it keep the text column. */
badge?: ReactNode
/** 2-depth preview shown beside the hovered / focused row (Figma 파일출력 2뎁스 662:67471). */
preview?: ReactNode
/** Preview caption; defaults to `path`. */
previewTitle?: string
disabled?: boolean
title?: string
}
+1
View File
@@ -10,6 +10,7 @@ export {
SWPopup, SWPopupSection, SWPopupRow,
type SWPopupProps, type SWPopupSectionProps, type SWPopupRowProps,
} from "./components/sw-popup/SWPopup"
export * from "./components/sw-home-menu"
// SW viewer mode (2026-10-19): viewer header, dropdown, popup panel and panel controls.
export * from "./components/sw-viewer"
+335
View File
@@ -0,0 +1,335 @@
/* @hm-design/sw — Home menu (Figma rj6wpsfk24lOWvWiVRAANP 26:10591 "헤더/꾸러미 ver2/home on", tiles 26:11355 …).
Scoped to .hm-sw-home-menu; reads the shared SW tokens of .hm-sw-header / .hm-sw-theme (font, text outline,
focus). New values are Figma values (Plugin API, 2026-10-19), never global selectors. */
.hm-sw-home-menu {
/* Section surface (Frame 2409–2412; Frame 2408 has slightly different stops): bottom → top + edge shade. */
--hm-sw-home-menu-section-background:
linear-gradient(0deg, #16120a 0%, rgba(22, 18, 10, 0) 54.458%, #16120a 100%),
linear-gradient(0deg, #18150e 28.102%, #262015 41.084%, #332b1c 58.06%, #38301f 81.027%, #18150e 100%);
--hm-sw-home-menu-section-border-color: #081813;
--hm-sw-home-menu-section-inner-border-color: #20190e;
/* Tile group g_* : fill bottom → top, 1px stroke top → bottom, drop shadow 0 2 r4 60%. */
--hm-sw-home-menu-group-background: linear-gradient(0deg, #19160e 2%, #38301f 43.678%, #463c29 67.668%, #2f281a 100%);
--hm-sw-home-menu-group-border: linear-gradient(180deg, #886d35 0%, #423625 8.846%, #f3dba8 26.169%, #0e0b06 83.792%, #574b30 100%);
--hm-sw-home-menu-group-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
/* Tile divider 1 x 44 (same gradient as the group stroke) with -1 0 r2 black shadow. */
--hm-sw-home-menu-divider: var(--hm-sw-home-menu-group-border);
--hm-sw-home-menu-divider-shadow: -1px 0 2px #000;
/* Tile states: Variant2 / Variant3 stroke (INSIDE 1px, top → bottom), icon and label color. */
--hm-sw-home-menu-item-hover-border: linear-gradient(180deg, #da6813 8.5%, #ffbb89 37.9%, #e5690d 71.9%, #ae3607 92.8%);
--hm-sw-home-menu-item-color: var(--hm-sw-header-text-color);
--hm-sw-home-menu-item-accent-color: #ff5c00;
--hm-sw-home-menu-icon-color: #b6b4af;
/* Title badge (tit_* Union, 9-slice of the Figma SVG) */
--hm-sw-home-menu-title-color: #000;
/* Bottom accent: 26:10591 per-section strip (top → bottom) / 662:67267 2px outline (bottom → top). */
--hm-sw-home-menu-accent-strip: linear-gradient(180deg, #5f4b0f -6.4%, #ff5100 96.8%);
--hm-sw-home-menu-accent-outline: linear-gradient(0deg, #ff5100 0%, #5f4b0f 76.7%);
/* Optional sub panel (Figma 최근파일 662:67331 / 파일출력 2뎁스 662:67471). */
--hm-sw-recent-files-width: 354px;
--hm-sw-recent-files-max-height: 750px;
--hm-sw-recent-files-background: #27241d;
--hm-sw-recent-files-border-color: #da6813;
--hm-sw-recent-files-path-color: #d4cbbd;
--hm-sw-recent-files-row-hover-background: var(--hm-sw-header-layer-row-hover-background);
--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-background: #62533d;
position: relative;
isolation: isolate;
width: max-content;
max-width: 100%;
}
.hm-sw-home-menu__sections {
display: flex;
align-items: flex-start;
max-width: 100%;
/* 2px of the accent strip shows left, right and under the sections (menu 1305 x 77 in Figma). */
padding: 0 2px 2px;
/* Not in Figma (1920px only): narrower hosts scroll the menu horizontally, like the property bar. */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
/* Sub panel under the menu, at its tile (position set from the DOM). */
.hm-sw-home-menu__panel { position: absolute; z-index: 1; top: 100%; }
.hm-sw-home-menu__section {
position: relative;
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: center;
height: 75px;
/* Figma: content 5px from the top and 10px from the sides, the 1px stroke included. */
padding: 4px 9px 0;
border: 1px solid var(--hm-sw-home-menu-section-border-color);
border-radius: 0 0 3px 3px;
background: var(--hm-sw-home-menu-section-background);
}
/* Frame 2412 (last): 261px, 11px left. Frame 2408 keeps 340px with its group centered. */
.hm-sw-home-menu__section:last-child { padding-left: 10px; }
/* Inner 1px #20190E (Rectangle 6977 / border, inset 1px). */
.hm-sw-home-menu__section::before {
content: "";
position: absolute;
inset: 0;
border: 1px solid var(--hm-sw-home-menu-section-inner-border-color);
border-radius: 0 0 2px 2px;
pointer-events: none;
}
/* 26:10591 Rectangle 6991–6995: 30px strip from y 47 behind the section, 2px visible under it. */
.hm-sw-home-menu[data-accent="sections"] .hm-sw-home-menu__section::after {
content: "";
position: absolute;
z-index: -1;
top: 46px;
right: -2px;
bottom: -3px;
left: -2px;
border-radius: 0 0 3px 3px;
background: var(--hm-sw-home-menu-accent-strip);
pointer-events: none;
}
.hm-sw-home-menu[data-accent="sections"] .hm-sw-home-menu__section:first-child::after { left: -3px; }
.hm-sw-home-menu[data-accent="sections"] .hm-sw-home-menu__section:last-child::after { right: -3px; }
/* 662:67267 Rectangle 6991: 1305 x 30 from y 47, 2px INSIDE gradient stroke. */
.hm-sw-home-menu[data-accent="outline"]::after {
content: "";
position: absolute;
z-index: -1;
top: 47px;
right: 0;
left: 0;
height: 30px;
padding: 2px;
border-radius: 0 0 3px 3px;
background: var(--hm-sw-home-menu-accent-outline);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
pointer-events: none;
}
/* tit_* : text + 23px each side; the Figma Union SVG (132 x 27 incl. its -2 r4 shadow) is 9-sliced. */
.hm-sw-home-menu__title {
position: relative;
z-index: 1;
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
height: 19px;
margin-bottom: -1px;
padding: 1px 23px 0;
color: var(--hm-sw-home-menu-title-color);
font-size: var(--hm-sw-header-label-size);
font-weight: 400;
line-height: 14px;
letter-spacing: 0.02em;
white-space: nowrap;
}
.hm-sw-home-menu__title::before {
content: "";
position: absolute;
z-index: -1;
inset: -6px -4px -2px;
border-style: solid;
border-width: 0 30px;
border-image: var(--hm-sw-home-menu-title-art) 0 30 fill / 0 30px / 0 stretch;
pointer-events: none;
}
.hm-sw-home-menu__group {
position: relative;
display: flex;
flex: 0 0 auto;
height: 46px;
border-radius: 3px;
background: var(--hm-sw-home-menu-group-background);
box-shadow: var(--hm-sw-home-menu-group-shadow);
}
/* INSIDE stroke drawn over the edge: tiles keep their full 80 x 46 like Figma. */
.hm-sw-home-menu__group::before {
content: "";
position: absolute;
inset: 0;
padding: 1px;
border-radius: inherit;
background: var(--hm-sw-home-menu-group-border);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
pointer-events: none;
}
.hm-sw-home-menu__item {
position: relative;
flex: 0 0 80px;
width: 80px;
height: 46px;
color: var(--hm-sw-home-menu-item-color);
}
.hm-sw-home-menu__item:first-child { border-radius: 3px 0 0 3px; }
.hm-sw-home-menu__item:last-child { border-radius: 0 3px 3px 0; }
.hm-sw-home-menu__item:only-child { border-radius: 3px; }
/* Divider at the right edge of every tile but the last. */
.hm-sw-home-menu__item:not(:last-child)::before {
content: "";
position: absolute;
z-index: 2;
top: 1px;
right: -1px;
width: 1px;
height: 44px;
background: var(--hm-sw-home-menu-divider);
box-shadow: var(--hm-sw-home-menu-divider-shadow);
pointer-events: none;
}
/* Variant2 / Variant3 stroke. */
.hm-sw-home-menu__item::after {
content: "";
position: absolute;
z-index: 3;
inset: 0;
padding: 1px;
border-radius: inherit;
background: var(--hm-sw-home-menu-item-hover-border);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
opacity: 0;
pointer-events: none;
}
/* 18 x 18 icon slot at (31, 6); larger art is centered on it and may overflow (Figma assets carry shadows). */
.hm-sw-home-menu__icon {
position: absolute;
top: 6px;
left: 31px;
display: grid;
place-items: center;
width: 18px;
height: 18px;
color: var(--hm-sw-home-menu-icon-color);
}
.hm-sw-home-menu__icon > * { flex: none; max-width: none; }
.hm-sw-home-menu__icon > :is(svg, img) { display: block; }
.hm-sw-home-menu__icon--hover { visibility: hidden; }
.hm-sw-home-menu__label {
position: absolute;
top: 25px;
left: 50%;
max-width: 76px;
height: 16px;
padding: 0 var(--hm-sw-header-text-outline-room);
overflow: hidden;
transform: translateX(-50%);
font-size: var(--hm-sw-header-font-size);
font-weight: 700;
line-height: 16px;
letter-spacing: 0.04em;
white-space: nowrap;
text-overflow: ellipsis;
text-shadow: var(--hm-sw-header-text-outline);
}
/* Variant2 (hover) = Variant3 (active): orange stroke, #FF5C00 icon and label, art 1px up-left. */
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"])::after { opacity: 1; }
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) {
--hm-sw-home-menu-icon-color: var(--hm-sw-home-menu-item-accent-color);
color: var(--hm-sw-home-menu-item-accent-color);
}
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon { transform: translate(-1px, -1px); }
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__label { top: 24px; }
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon:not(.hm-sw-home-menu__icon--hover) { visibility: hidden; }
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon--hover { visibility: visible; }
/* SWRecentFiles — Figma 최근파일 662:67331 (354 x 750: rows 50px) + 2-depth preview 662:67471 (310 x 270). */
.hm-sw-recent-files { position: relative; width: var(--hm-sw-recent-files-width); }
/* Frame stroke: 1px #DA6813 left / right / bottom over the list edge (joins the menu's outline accent). */
.hm-sw-recent-files::after {
content: "";
position: absolute;
z-index: 2;
inset: 0;
border: 1px solid var(--hm-sw-recent-files-border-color);
border-top: 0;
border-radius: 0 0 3px 3px;
pointer-events: none;
}
.hm-sw-recent-files__list {
box-sizing: content-box; /* max-height = rows (15 x 50 = Figma 750) */
max-height: var(--hm-sw-recent-files-max-height);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
border: 1px solid var(--hm-sw-recent-files-background);
border-radius: 0 0 4px 4px;
background: var(--hm-sw-recent-files-background);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25), inset 0 4px 8px rgba(0, 0, 0, 0.4);
}
.hm-sw-recent-files__row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 50px;
padding: 1px 8px;
text-align: left;
}
/* Rows without a badge keep the text column at 45px (Figma px 45). */
.hm-sw-recent-files__row:not(:has(> .hm-sw-recent-files__badge)) { padding-left: 45px; }
.hm-sw-recent-files__badge { display: grid; flex: 0 0 29px; place-items: center; width: 29px; height: 28px; }
.hm-sw-recent-files__badge > :is(svg, img) { display: block; max-width: none; }
.hm-sw-recent-files__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; max-width: 301px; }
.hm-sw-recent-files__name,
.hm-sw-recent-files__path {
display: block;
overflow: hidden;
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
white-space: nowrap;
text-overflow: ellipsis;
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__path { color: var(--hm-sw-recent-files-path-color); font-size: var(--hm-sw-header-label-size); line-height: 14px; 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) .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; }
/* 2-depth preview beside the row (1px gap), top aligned 1px under the row top. */
.hm-sw-recent-files__preview {
position: absolute;
z-index: 3;
left: calc(100% + 1px);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: var(--hm-sw-recent-files-preview-width);
padding: 3px 0 0; /* + 1px border = Figma py 4; 270px total with the 243px body */
border: 1px solid var(--hm-sw-recent-files-preview-background);
border-left: 0;
border-radius: 0 3px 4px 0;
background: var(--hm-sw-recent-files-preview-background);
box-shadow: inset 2px 0 2.5px rgba(0, 0, 0, 0.5);
filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.5));
pointer-events: none;
}
.hm-sw-recent-files__preview-title {
width: 294px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--hm-sw-recent-files-row-hover-color);
font-size: var(--hm-sw-header-font-size);
line-height: 18px;
letter-spacing: 0.02em;
}
.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 > :is(img, svg, canvas) { display: block; width: 100%; height: 100%; object-fit: cover; }
+5
View File
@@ -1876,3 +1876,8 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control:not([data-control="document"]) > * { flex-shrink: 0; }
/* ...and the file name gives way only down to the default design's smallest width (96px, see the clamps above). */
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control[data-control="document"] { min-width: 96px; }
/* Logo (home) menu layer (SWHeader homeMenu, 2026-10-19). Top of the header, left / max-width measured from the
object area (−2px) to the window area. Above the property bar anchor (2), under the dropdowns (30) and the
property window (40). The logo, the file name and the window area stay visible beside it. */
.hm-sw-header__home-menu { position: absolute; top: 0; z-index: 20; }
+1 -1
View File
@@ -31,7 +31,7 @@ const docs = (await readdir(path.join(root, "ai"))).filter(name => name.endsWith
for (const doc of docs) {
const text = await readFile(path.join(root, "ai", doc), "utf8")
const notPublic = new Set([...(text.split(/^## .*공개하지 않은 것.*$/m)[1]?.split(/^## /m)[0] ?? "").matchAll(/`(\w+)`/g)].map(match => match[1]))
const mentioned = [...new Set([...text.matchAll(/`((?:SW|parseSW|swHeader|useSW)\w+)/g)].map(match => match[1]))]
const mentioned = [...new Set([...text.matchAll(/`((?:SW|parseSW|swHeader|useSW)\w+)(?![\w*])/g)].map(match => match[1]))]
const unknown = mentioned.filter(name => !publicNames.has(name) && !notPublic.has(name) && !/^SWSearchControl/.test(name))
check(`${doc}: named SW identifiers are public exports`, unknown.length === 0, { mentioned: mentioned.length, unknown })
const leaked = [...notPublic].filter(name => publicNames.has(name))
@@ -0,0 +1,171 @@
// Core checks of the Task A + B integration (SWHeader homeMenu, 2026-10-19) on the built Storybook.
// Usage: node packages/sw/tests/home-menu-integration.browser.mjs <evidence-dir> (build Storybook first)
import { createRequire } from 'node:module'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const out = path.resolve(process.argv[2] ?? 'docs/sw/home-menu-integration-qa/home-menu')
await mkdir(out, { recursive: true })
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(root, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
const { chromium } = require('playwright')
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } })
const report = { browser: browser.version(), checks: [], errors: [], data: {} }
page.on('pageerror', e => report.errors.push(e.message))
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, ok || data === undefined ? '' : JSON.stringify(data)) }
const goto = async id => {
await page.goto(`${base}/iframe.html?id=sw-home-menu--${id}&viewMode=story`)
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
await page.evaluate(() => document.fonts.ready)
await page.mouse.move(1, 1)
await page.waitForTimeout(100)
}
const menu = () => page.locator('.hm-sw-header .hm-sw-home-menu')
const home = () => page.locator('.hm-sw-header__home').first()
const log = () => page.locator('.sw-home-menu-story__log').innerText()
const activeIsHome = () => page.evaluate(() => document.activeElement?.classList.contains('hm-sw-header__home'))
// 1. Storybook menu structure
const index = JSON.parse(await readFile(path.join(root, 'index.json'), 'utf8'))
const titles = [...new Set(Object.values(index.entries).map(entry => entry.title).filter(title => title.startsWith('SW/')))].sort()
report.data.titles = titles
check('Storybook SW titles: Header, Popup, Home Menu, Viewer (SW/Tool removed 2026-10-19); no SW/Header/Options', ['SW/Header', 'SW/Popup', 'SW/Home Menu', 'SW/Viewer'].every(t => titles.includes(t)) && !titles.includes('SW/Tool') && !titles.some(t => t.startsWith('SW/Header/')), titles)
// 2. Figma 26:10591 geometry at 1920 through SWHeader's measured placement
await goto('capture-default')
const geometry = await page.evaluate(() => {
const header = document.querySelector('.hm-sw-header').getBoundingClientRect()
const m = document.querySelector('.hm-sw-home-menu').getBoundingClientRect()
const r = e => e.getBoundingClientRect()
const tiles = [...document.querySelectorAll('.hm-sw-home-menu__item')].map(r)
const badge = getComputedStyle(document.querySelector('.hm-sw-home-menu')).getPropertyValue('--hm-sw-home-menu-title-art')
const icons = [...document.querySelectorAll('.hm-sw-home-menu__icon img')]
return {
left: m.left - header.left, width: m.width, height: m.height, headerHeight: header.height,
sections: [...document.querySelectorAll('.hm-sw-home-menu__section')].map(e => Math.round(r(e).width)),
tiles: tiles.length, tileSizes: [...new Set(tiles.map(t => `${Math.round(t.width)}x${Math.round(t.height)}`))],
titles: document.querySelectorAll('.hm-sw-home-menu__title').length,
badgeInlined: badge.includes('data:image/svg+xml') || badge.includes('.svg'),
icons: icons.length, iconsLoaded: icons.filter(i => i.complete && i.naturalWidth > 0).length,
}
})
report.data.geometry = geometry
check('menu at Figma x 375, width 1305, height 77 (measured, no fixed x); header stays 49', Math.abs(geometry.left - 375) < 0.6 && Math.abs(geometry.width - 1305) < 0.6 && Math.abs(geometry.height - 77) < 0.6 && geometry.headerHeight === 49, geometry)
check('5 sections 340/260/180/260/261, 15 tiles 80x46, 5 titles', JSON.stringify(geometry.sections) === '[340,260,180,260,261]' && geometry.tiles === 15 && geometry.tileSizes.join() === '80x46' && geometry.titles === 5, geometry)
check('title badge SVG and 30 tile icons render', geometry.badgeInlined && geometry.icons === 30 && geometry.iconsLoaded === 30, geometry)
await page.screenshot({ path: path.join(out, 'capture-default-1920.png'), clip: { x: 0, y: 0, width: 1920, height: 80 } })
// 3. Hidden by default, logo opens / re-click closes, onHomeClick not called, Hover / Active
await goto('header-composition')
check('hidden by default (not in DOM), logo aria-expanded=false', await menu().count() === 0 && await home().getAttribute('aria-expanded') === 'false')
await home().click()
check('logo click opens, aria-expanded=true, aria-controls → menu id', await menu().count() === 1 && await home().getAttribute('aria-expanded') === 'true' && await home().getAttribute('aria-controls') === await menu().getAttribute('id'))
const tile = page.locator('.hm-sw-home-menu__item[data-item-id="save"]')
await tile.hover()
await page.waitForTimeout(50)
const hover = await tile.evaluate(e => getComputedStyle(e.querySelector('.hm-sw-home-menu__label')).color)
check('tile Hover: label #FF5C00', hover === 'rgb(255, 92, 0)', hover)
await tile.click()
const pressedOn = await tile.getAttribute('aria-pressed')
await tile.click()
check('tile click → Active (aria-pressed true), re-click → Default', pressedOn === 'true' && await tile.getAttribute('aria-pressed') === 'false', pressedOn)
await home().click()
const text = await log()
check('logo re-click closes; onHomeClick never called while homeMenu is set', await menu().count() === 0 && !text.includes('onHomeClick') && text.includes('onHomeMenuOpenChange(false, home)'), text)
// 4. Escape + focus, background click, sub panel first
await home().click()
await page.keyboard.press('Escape')
check('Escape inside closes the menu and returns focus to the logo', await menu().count() === 0 && await activeIsHome())
await home().click()
await page.locator('.hm-sw-home-menu__title').first().click()
check('click on the menu background closes it (Figma CLOSE)', await menu().count() === 0)
await home().click()
await page.locator('.hm-sw-home-menu__item[data-item-id="recent"]').click()
const panelOpen = await page.locator('.hm-sw-home-menu__panel').count() === 1 && await menu().getAttribute('data-accent') === 'outline'
await page.keyboard.press('Escape')
const panelFirst = await page.locator('.hm-sw-home-menu__panel').count() === 0 && await menu().count() === 1
await page.keyboard.press('Escape')
check('recent panel opens (outline accent); Escape closes the panel, then the menu', panelOpen && panelFirst && await menu().count() === 0, { panelOpen, panelFirst })
// 5. Document dropdown keeps New / Open and never stays open with the menu
await home().click()
await page.locator('.hm-sw-header__document-button').first().click()
const docMenu = page.locator('.hm-sw-header__document-menu')
const footer = await docMenu.innerText().catch(() => '')
check('opening the document dropdown closes the menu (reason document); New / Open stay in the dropdown', await menu().count() === 0 && footer.includes('New') && footer.includes('Open') && (await log()).includes('onHomeMenuOpenChange(false, document)'), footer)
await home().click()
check('opening the menu closes the document dropdown', await menu().count() === 1 && await docMenu.count() === 0)
check('no separate Open button next to the logo (documentOpenPlacement="menu")', await page.locator('[data-document-open]').count() === 0)
await page.screenshot({ path: path.join(out, 'header-composition-open.png'), clip: { x: 0, y: 0, width: 1920, height: 400 } })
// 6. Menu + SWPopup + property window: Escape order and focus outside
await goto('integration')
const popup = page.locator('.hm-sw-property:has-text("사용자 Popup")')
const blur = () => page.locator('.story-page-header, h1').first().click({ position: { x: 2, y: 2 } }).catch(() => page.mouse.click(1900, 990))
await page.getByTestId('open-popup').click()
await home().click()
await blur()
await page.keyboard.press('Escape')
const step1 = { menu: await menu().count(), popup: await popup.count() }
await page.keyboard.press('Escape')
const step2 = { menu: await menu().count(), popup: await popup.count() }
check('focus outside: Escape closes the most recent (menu), then the popup', step1.menu === 0 && step1.popup === 1 && step2.popup === 0, { step1, step2 })
await home().click()
await page.getByTestId('open-popup').click()
await blur()
await page.keyboard.press('Escape')
const step3 = { menu: await menu().count(), popup: await popup.count() }
check('popup opened after the menu: Escape outside closes the popup first, menu stays', step3.popup === 0 && step3.menu === 1, step3)
await page.getByTestId('open-popup').click()
await popup.locator('button').first().focus()
await page.keyboard.press('Escape')
const step4 = { menu: await menu().count(), popup: await popup.count() }
check('Escape inside the popup closes only the popup', step4.popup === 0 && step4.menu === 1, step4)
await page.locator('.hm-sw-home-menu__item').first().focus()
await page.getByTestId('open-popup').click()
await page.locator('.hm-sw-home-menu__item').first().focus()
await page.keyboard.press('Escape')
const step5 = { menu: await menu().count(), popup: await popup.count(), focusHome: await activeIsHome() }
check('Escape inside the menu closes only the menu (popup stays), focus → logo', step5.menu === 0 && step5.popup === 1 && step5.focusHome, step5)
// 7. Responsive placement (measured), internal scroll reported
const responsive = []
for (const width of [1920, 1440, 1080, 768, 520, 390]) {
await page.setViewportSize({ width, height: 900 })
await goto('capture-responsive')
const box = await page.evaluate(() => {
const r = s => document.querySelector(s)?.getBoundingClientRect()
const header = r('.hm-sw-header'), area = r('.hm-sw-header__object-area'), win = r('.hm-sw-header__window'), m = r('.hm-sw-home-menu'), homeBox = r('.hm-sw-header__home'), doc = r('.hm-sw-header__document')
const scroller = document.querySelector('.hm-sw-home-menu__sections')
return { headerHeight: header.height, start: m.left - (area.left - 2), endGap: (win?.left ?? header.right) - m.right, coversHome: m.left < homeBox.right - 17, coversDocument: m.left < doc.right - 1, menuWidth: Math.round(m.width), scrolls: scroller.scrollWidth > scroller.clientWidth + 1, pageScroll: document.documentElement.scrollWidth > innerWidth }
})
responsive.push({ width, ...box })
if (width === 390 || width === 768) await page.screenshot({ path: path.join(out, `responsive-${width}.png`), clip: { x: 0, y: 0, width, height: 90 } })
}
report.data.responsive = responsive
check('every width: menu starts at object area −2px, ends at the window area, covers neither logo nor file name, header 49, no page scroll',
responsive.every(r => Math.abs(r.start) < 0.6 && r.endGap > -0.6 && !r.coversHome && !r.coversDocument && r.headerHeight === 49 && !r.pageScroll), responsive)
// 8. Without homeMenu (existing Header): the logo still calls onHomeClick only, no menu, no aria-expanded
await page.setViewportSize({ width: 1920, height: 1000 })
const infos = []
page.on('console', m => { if (m.type() === 'info') infos.push(m.text()) })
await page.goto(`${base}/iframe.html?id=sw-header--overview&viewMode=story`)
await page.locator('.hm-sw-header').first().waitFor()
await home().click()
await page.waitForTimeout(50)
check('without homeMenu: logo click calls onHomeClick, renders no menu, no aria-expanded', infos.some(text => text.includes('onHomeClick')) && await page.locator('.hm-sw-home-menu').count() === 0 && await home().getAttribute('aria-expanded') === null, infos)
check('no page / console errors', report.errors.length === 0, report.errors)
await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2))
console.log(`${report.checks.filter(c => c.status === 'PASS').length}/${report.checks.length} PASS`)
await browser.close(); server.close()
process.exitCode = report.checks.every(c => c.status === 'PASS') ? 0 : 1
+2 -2
View File
@@ -212,7 +212,7 @@ import { Button, Card, CardContent, FilterBar, FilterBarActions, FilterBarSearch
### 예시 B1 — SW 일반 모드 (편집 Header + 도구 막대 + 설정 Popup)
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(일반 모드) → COMPOSITION_PATTERNS "6. 파일 열기 + 도구 막대", 예제 `ai/examples/ExampleF-ViewerToolbar.tsx`(파일 이름의 Viewer는 과거 명칭이며 일반 모드 `SWHeader` 예제)
- 재사용: `SWHeader`(`documentOpenPlacement="header"`, `controls`, `tools`), `SWToolGroup` + `SWToolButton`(Model/Layout 탭, Fit 등), `SWPopup` + `SWPopupSection` + `SWPopupRow` + `SWHeaderInput`(설정 Popup)
- 재사용: `SWHeader`(`documentOpenPlacement="header"`, `controls`, `tools`), `SWToolGroup` + `SWToolButton`(Model/Layout 탭, Fit 등), `SWPopup` + `SWPopupSection` + `SWPopupRow` + `SWHeaderInput`(설정 Popup), 로고(홈) 버튼 메뉴는 `SWHeader`의 `homeMenu` + `SWRecentFiles`(**일반 모드 전용**, 예제 `ExampleG-HomeMenu.tsx`)
- 신규: SW에 없는 기능(예: 검색) → 소비 프로젝트 컴포넌트, `<SWTheme>` 안, `--hm-sw-header-*` 토큰, 안쪽 입력은 `SWHeaderInput`(예제 `ExampleD-SearchExtension.tsx`)
```tsx
@@ -225,7 +225,7 @@ import "@hm-design/sw/styles.css"
- 읽기: `node_modules/@hm-design/sw/ai/` → AI_USAGE §0(뷰어 모드) → `VIEWER.md`, 예제 `ai/examples/ExampleH-ViewerMode.tsx`
- 재사용: `SWViewerHeader`(`groups` 데이터: `button`·`toggle`·`menu`·`tabs`·`switch`·`custom` 항목, `onCommand`, `trailing`에 `SWViewerStatus`·`SWViewerHeaderButton`), Dropdown은 `type: "menu"` 항목의 `entries`(단독·우클릭은 `SWViewerMenu`), 팝업은 `SWViewerPanel` + `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`
- 신규: 뷰어에 없는 패널 콘텐츠 → 소비 프로젝트 컴포넌트를 `SWViewerPanel`의 `children`에, 안쪽은 `SWViewerField` + `SWViewerInput` 등, 외형은 `--hm-sw-viewer-*` 토큰
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기, 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
- 하지 않음: `SWHeader`·`SWPopup`을 뷰어 화면에 섞기, 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`·`homeMenu`)를 뷰어 화면에 넣거나 비슷하게 만들기(홈 메뉴는 일반 모드 전용), 로고가 없다고 임의 문자·아이콘 넣기, 연결할 명령이 없는 메뉴를 가짜 동작으로 만들기(제외 또는 `disabled` + 미연결 보고)
```tsx
import { SWViewerHeader, SWViewerPanel, SWViewerSection, SWViewerField, SWViewerSwitch, type SWViewerHeaderGroup } from "@hm-design/sw"
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+26
View File
@@ -0,0 +1,26 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="23" height="22" viewBox="0 0 23 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="inventory_2">
<g id="Vector" filter="url(#filter0_dd_0_10)">
<path d="M17.5 4H5.5C4.75 4 4 4.675 4 5.5V7.7575C4 8.2975 4.3225 8.7625 4.75 9.025V16.5C4.75 17.325 5.575 18 6.25 18H16.75C17.425 18 18.25 17.325 18.25 16.5V9.025C18.6775 8.7625 19 8.2975 19 7.7575V5.5C19 4.675 18.25 4 17.5 4ZM16.75 16.5H6.25V9.25H16.75V16.5ZM17.5 7.75H5.5V5.5H17.5V7.75Z" fill="#FF5C00"/>
<path d="M13.75 11.5H9.25V13H13.75V11.5Z" fill="#FF5C00"/>
</g>
</g>
<defs>
<filter id="filter0_dd_0_10" x="0" y="0" width="23" height="22" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_10"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_10" result="effect2_dropShadow_0_10"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_10" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+26
View File
@@ -0,0 +1,26 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="23" height="22" viewBox="0 0 23 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="inventory_2">
<g id="Vector" filter="url(#filter0_dd_0_451)">
<path d="M17.5 4H5.5C4.75 4 4 4.675 4 5.5V7.7575C4 8.2975 4.3225 8.7625 4.75 9.025V16.5C4.75 17.325 5.575 18 6.25 18H16.75C17.425 18 18.25 17.325 18.25 16.5V9.025C18.6775 8.7625 19 8.2975 19 7.7575V5.5C19 4.675 18.25 4 17.5 4ZM16.75 16.5H6.25V9.25H16.75V16.5ZM17.5 7.75H5.5V5.5H17.5V7.75Z" fill="#B6B4AF"/>
<path d="M13.75 11.5H9.25V13H13.75V11.5Z" fill="#B6B4AF"/>
</g>
</g>
<defs>
<filter id="filter0_dd_0_451" x="0" y="0" width="23" height="22" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_451"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_451" result="effect2_dropShadow_0_451"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_451" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="monitor" clip-path="url(#clip0_0_10)">
<g id="Vector" filter="url(#filter0_dd_0_10)">
<path d="M18.4 3.55H5.6C4.72 3.55 4 4.27 4 5.15V13.95C4 14.83 4.72 15.55 5.6 15.55H9.6L8.8 16.35V17.95H15.2V16.35L14.4 15.55H18.4C19.28 15.55 20 14.83 20 13.95V5.15C20 4.27 19.28 3.55 18.4 3.55ZM18.4 13.95H5.6V5.15H18.4V13.95Z" fill="#FF5C00"/>
</g>
</g>
<defs>
<filter id="filter0_dd_0_10" x="-0.266667" y="-0.716667" width="24.5333" height="22.9333" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2.13333"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_10"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.533333"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_10" result="effect2_dropShadow_0_10"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_10" result="shape"/>
</filter>
<clipPath id="clip0_0_10">
<rect width="24" height="22" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="monitor" clip-path="url(#clip0_0_405)">
<g id="Vector" filter="url(#filter0_dd_0_405)">
<path d="M18.4 3.55H5.6C4.72 3.55 4 4.27 4 5.15V13.95C4 14.83 4.72 15.55 5.6 15.55H9.6L8.8 16.35V17.95H15.2V16.35L14.4 15.55H18.4C19.28 15.55 20 14.83 20 13.95V5.15C20 4.27 19.28 3.55 18.4 3.55ZM18.4 13.95H5.6V5.15H18.4V13.95Z" fill="#B6B4AF"/>
</g>
</g>
<defs>
<filter id="filter0_dd_0_405" x="-0.266667" y="-0.716667" width="24.5333" height="22.9333" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2.13333"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_405"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.533333"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_405" result="effect2_dropShadow_0_405"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_405" result="shape"/>
</filter>
<clipPath id="clip0_0_405">
<rect width="24" height="22" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="22.52" height="23.2775" viewBox="0 0 22.52 23.2775" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="file_open" opacity="0.9">
<g id="Vector" filter="url(#filter0_dd_0_10)">
<path d="M12.25 19H5.5C4.675 19 4 18.325 4 17.5V5.5C4 4.675 4.675 4 5.5 4H11.5L16 8.5V13H14.5V9.25H10.75V5.5H5.5V17.5H12.25V19ZM17.02 18.745V17.065L14.8075 19.2775L13.75 18.22L15.9625 16H14.2825V14.5H18.52V18.745H17.02Z" fill="#FF5C00"/>
</g>
</g>
<defs>
<filter id="filter0_dd_0_10" x="0" y="0" width="22.52" height="23.2775" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_10"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_10" result="effect2_dropShadow_0_10"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_10" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="22.52" height="23.2775" viewBox="0 0 22.52 23.2775" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="file_open" opacity="0.9">
<g id="Vector" filter="url(#filter0_dd_0_360)">
<path d="M12.25 19H5.5C4.675 19 4 18.325 4 17.5V5.5C4 4.675 4.675 4 5.5 4H11.5L16 8.5V13H14.5V9.25H10.75V5.5H5.5V17.5H12.25V19ZM17.02 18.745V17.065L14.8075 19.2775L13.75 18.22L15.9625 16H14.2825V14.5H18.52V18.745H17.02Z" fill="#B6B4AF"/>
</g>
</g>
<defs>
<filter id="filter0_dd_0_360" x="0" y="0" width="22.52" height="23.2775" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_360"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_360" result="effect2_dropShadow_0_360"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_360" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,30 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="37" height="32" viewBox="0 0 37 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon_dualmonitor" filter="url(#filter0_d_0_259)">
<g id="Union">
<path d="M12 14V13H18V4H4V13H10V14H8V15H10H12H14V14H12Z" fill-rule="evenodd" clip-rule="evenodd" fill="#FFE500"/>
<mask id="stroke0_0_259" fill="white">
<path d="M12 14V13H18V4H4V13H10V14H8V15H10H12H14V14H12Z" fill-rule="evenodd" clip-rule="evenodd"/>
</mask>
<path d="M12 13V14H14V15H12H10H8V14H10V13H4V4H18V13H12Z" stroke-width="2" stroke="#264F42" mask="url(#stroke0_0_259)"/>
</g>
<g id="Union_2">
<path d="M27 14V13H33V4H19V13H25V14H23V15H25H27H29V14H27Z" fill-rule="evenodd" clip-rule="evenodd" fill="white"/>
<mask id="stroke1_0_259" fill="white">
<path d="M27 14V13H33V4H19V13H25V14H23V15H25H27H29V14H27Z" fill-rule="evenodd" clip-rule="evenodd"/>
</mask>
<path d="M27 13V14H29V15H27H25H23V14H25V13H19V4H33V13H27Z" stroke-width="2" stroke="#4B7165" mask="url(#stroke1_0_259)"/>
</g>
</g>
<defs>
<filter id="filter0_d_0_259" x="0" y="0" width="37" height="36" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_259"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_0_259" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,30 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="37" height="32" viewBox="0 0 37 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon_dualmonitor" filter="url(#filter0_d_0_233)">
<g id="Union">
<path d="M12 14V13H18V4H4V13H10V14H8V15H10H12H14V14H12Z" fill-rule="evenodd" clip-rule="evenodd" fill="white"/>
<mask id="stroke0_0_233" fill="white">
<path d="M12 14V13H18V4H4V13H10V14H8V15H10H12H14V14H12Z" fill-rule="evenodd" clip-rule="evenodd"/>
</mask>
<path d="M12 13V14H14V15H12H10H8V14H10V13H4V4H18V13H12Z" stroke-width="2" stroke="#4B7165" mask="url(#stroke0_0_233)"/>
</g>
<g id="Union_2">
<path d="M27 14V13H33V4H19V13H25V14H23V15H25H27H29V14H27Z" fill-rule="evenodd" clip-rule="evenodd" fill="#FFE500"/>
<mask id="stroke1_0_233" fill="white">
<path d="M27 14V13H33V4H19V13H25V14H23V15H25H27H29V14H27Z" fill-rule="evenodd" clip-rule="evenodd"/>
</mask>
<path d="M27 13V14H29V15H27H25H23V14H25V13H19V4H33V13H27Z" stroke-width="2" stroke="#264F42" mask="url(#stroke1_0_233)"/>
</g>
</g>
<defs>
<filter id="filter0_d_0_233" x="0" y="0" width="37" height="36" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_233"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_0_233" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,25 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24.6989" height="20" viewBox="0 0 24.6989 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="folder_open">
<g id="Union" filter="url(#filter0_dd_0_10)">
<path d="M17.5 5.5H11.5L10 4H5.5C4.675 4 4.0075 4.675 4.0075 5.5L4 14.5C4 15.325 4.675 16 5.5 16H17.5C18.325 16 19 15.325 19 14.5L20.6646 8.25766C20.8339 7.62282 20.3554 7 19.6984 7H19.5H19C19 6.175 18.325 5.5 17.5 5.5ZM5.5 8.5V14.5H17.5L19.1 8.5H5.5Z" fill-rule="evenodd" clip-rule="evenodd" fill="#FF5C00"/>
</g>
</g>
<defs>
<filter id="filter0_dd_0_10" x="0" y="0" width="24.6989" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_10"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_10" result="effect2_dropShadow_0_10"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_10" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24.6989" height="20" viewBox="0 0 24.6989 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="folder_open">
<g id="Union" filter="url(#filter0_dd_0_505)">
<path d="M17.5 5.5H11.5L10 4H5.5C4.675 4 4.0075 4.675 4.0075 5.5L4 14.5C4 15.325 4.675 16 5.5 16H17.5C18.325 16 19 15.325 19 14.5L20.6646 8.25766C20.8339 7.62282 20.3554 7 19.6984 7H19.5H19C19 6.175 18.325 5.5 17.5 5.5ZM5.5 8.5V14.5H17.5L19.1 8.5H5.5Z" fill-rule="evenodd" clip-rule="evenodd" fill="#B6B4AF"/>
</g>
</g>
<defs>
<filter id="filter0_dd_0_505" x="0" y="0" width="24.6989" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_505"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_505" result="effect2_dropShadow_0_505"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_505" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="content_paste" clip-path="url(#clip0_0_10)">
<g id="Vector" filter="url(#filter0_dd_0_10)">
<path d="M14.25 2.5H11.115C10.8 1.63 9.975 1 9 1C8.025 1 7.2 1.63 6.885 2.5H3.75C2.925 2.5 2.25 3.175 2.25 4V15C2.25 15.825 2.925 16.5 3.75 16.5H14.25C15.075 16.5 15.75 15.825 15.75 15V4C15.75 3.175 15.075 2.5 14.25 2.5ZM9 2.5C9.4125 2.5 9.75 2.8375 9.75 3.25C9.75 3.6625 9.4125 4 9 4C8.5875 4 8.25 3.6625 8.25 3.25C8.25 2.8375 8.5875 2.5 9 2.5ZM14.25 15H3.75V4H5.25V6.25H12.75V4H14.25V15Z" fill="#FF5C00"/>
</g>
</g>
<defs>
<filter id="filter0_dd_0_10" x="-1.75" y="-3" width="21.5" height="23.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_10"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_10" result="effect2_dropShadow_0_10"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_10" result="shape"/>
</filter>
<clipPath id="clip0_0_10">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="content_paste" clip-path="url(#clip0_0_344)">
<g id="Vector" filter="url(#filter0_dd_0_344)">
<path d="M14.25 2.5H11.115C10.8 1.63 9.975 1 9 1C8.025 1 7.2 1.63 6.885 2.5H3.75C2.925 2.5 2.25 3.175 2.25 4V15C2.25 15.825 2.925 16.5 3.75 16.5H14.25C15.075 16.5 15.75 15.825 15.75 15V4C15.75 3.175 15.075 2.5 14.25 2.5ZM9 2.5C9.4125 2.5 9.75 2.8375 9.75 3.25C9.75 3.6625 9.4125 4 9 4C8.5875 4 8.25 3.6625 8.25 3.25C8.25 2.8375 8.5875 2.5 9 2.5ZM14.25 15H3.75V4H5.25V6.25H12.75V4H14.25V15Z" fill="#B6B4AF"/>
</g>
</g>
<defs>
<filter id="filter0_dd_0_344" x="-1.75" y="-3" width="21.5" height="23.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_344"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_344" result="effect2_dropShadow_0_344"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_344" result="shape"/>
</filter>
<clipPath id="clip0_0_344">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="23" height="23" viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="question_answer">
<g id="Vector" filter="url(#filter0_dd_0_10)">
<path d="M13.75 5.5V10.75H6.3775L5.5 11.6275V5.5H13.75ZM14.5 4H4.75C4.3375 4 4 4.3375 4 4.75V15.25L7 12.25H14.5C14.9125 12.25 15.25 11.9125 15.25 11.5V4.75C15.25 4.3375 14.9125 4 14.5 4ZM18.25 7H16.75V13.75H7V15.25C7 15.6625 7.3375 16 7.75 16H16L19 19V7.75C19 7.3375 18.6625 7 18.25 7Z" fill="#FF5C00"/>
</g>
</g>
<defs>
<filter id="filter0_dd_0_10" x="0" y="0" width="23" height="23" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_10"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_10" result="effect2_dropShadow_0_10"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_10" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more