feat(sw): add step header with multi-step and split layout support
This commit is contained in:
1 parent
6ca92c5d55
commit
adbb034cb1
28 files changed
+2477
-7
No files matched your search
@@ -48,15 +48,16 @@ SW 패키지에는 Header·Popup 계열이 두 벌 있습니다. 화면마다 **
|
||||
| 아이콘 | 패키지 자산(필드 안 아이콘) | 프로그램 아이콘 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~8·§10, [DESIGN_RULES.md](./DESIGN_RULES.md) §1~10·§12·§13, [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) 1~6·8·10, [FOOTER.md](./FOOTER.md), 예제 A~G·I | [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) |
|
||||
| 읽을 문서 | [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) §1~8·§10·§11, [DESIGN_RULES.md](./DESIGN_RULES.md) §1~10·§12·§13·§14, [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) 1~6·8·10·12, [FOOTER.md](./FOOTER.md), 예제 A~G·I | [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**(로고 · 파일명 · 01 Modeling → 02 Analysis Result 같은 단계 탭 · Layer 필드 · 도구 pill · 최소화 · 최대화/복원 · 종료, Strana Main Header) 요청은 일반 모드의 독립 Header `SWProcessHeader`입니다(카탈로그 §11, DESIGN_RULES §14, PATTERNS 12). 그룹·단계가 많은 다중 Step 화면(StrAna-SW)은 같은 컴포넌트의 `appearance="multi"`입니다(별도 컴포넌트를 만들지 않음). `SWHeader` 대신 쓰며 같은 화면에 함께 두지 않습니다.
|
||||
- **하단 명령 막대(Footer)** 요청은 일반 모드입니다: [FOOTER.md](./FOOTER.md) §1 순서로 `SWFooter`가 있는지 확인하고, 명령·분류·보조기능·문구·아이콘만 Props로 바꿉니다. Footer 외형을 새로 그리지 않습니다.
|
||||
- **일반 모드 Header에 도구 막대를 더한 구성**(로고 옆 파일 열기 + Model/Layout·Fit·2.5D 버튼을 `SWHeader`에 추가)은 **일반 모드**입니다: `SWHeader`의 `documentOpenPlacement`·`leading`·`tools` + `SWToolButton` / `SWToolGroup`(PATTERNS 6, 예제 F). "뷰어"·"도면 보기" 화면을 요청받았다면 이 구성이 아니라 뷰어 모드(`SWViewer*`)를 씁니다.
|
||||
- 모드 진입·종료, 파일 처리, 도면 렌더링, 메뉴 명령 실행은 어느 모드든 **프로그램 담당**입니다.
|
||||
|
||||
### 0.3 모드 혼합 금지
|
||||
|
||||
- **한 화면에 Header 하나.** `SWHeader`와 `SWViewerHeader`를 같은 화면에 함께 두지 않습니다. 두 모드를 오가는 프로그램은 상태를 유지한 채 Header 컴포넌트만 바꿉니다(VIEWER.md §1).
|
||||
- **한 화면에 Header 하나.** `SWHeader`·`SWProcessHeader`·`SWViewerHeader` 중 둘 이상을 같은 화면에 함께 두지 않습니다. 두 모드를 오가는 프로그램은 상태를 유지한 채 Header 컴포넌트만 바꿉니다(VIEWER.md §1).
|
||||
- **뷰어 화면:** `SWHeader`에 뷰어 메뉴를 넣거나 `SWHeader`·`SWPopup`을 뷰어처럼 재스타일하지 않습니다. 뷰어 팝업은 `SWViewerPanel`입니다. 예외: 창 최소화·닫기 버전은 `SWViewerHeader`의 `trailing` 맨 끝에 `SWHeaderWindowControls`(`showWindowMove={false}`)를 그대로 둡니다(VIEWER.md §2.1, 2026-10-21).
|
||||
- **일반 화면:** `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`과 패널 컨트롤(`SWViewerSection` …)을 쓰지 않습니다.
|
||||
- **로고(홈) 메뉴는 SW 일반 모드 전용:** `SWHomeMenu`·`SWHomeMenuSection`·`SWHomeMenuItem`·`SWRecentFiles`·`useSWHomeMenuClose`와 `SWHeader`의 `homeMenu`·`homeMenuOpen`·`defaultHomeMenuOpen`·`onHomeMenuOpenChange`는 일반 모드 `SWHeader` 화면에서만 씁니다. 뷰어 화면에는 홈 메뉴를 만들지 않고(`SWViewerHeader` 옆·`custom` 항목·`SWViewerPanel` 안에 넣거나 비슷하게 다시 만들지 않음), 뷰어 Header와 기능은 그대로 둡니다.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- **검증 표시:** ✅ = 2026-10-19 독립 소비 프로젝트(Tarball 설치)에서 브라우저 검증, ◻ = 소비 검증 범위 밖(Storybook 검증 기록만 있음).
|
||||
- **먼저 모드:** SW 일반 모드(편집) / SW 뷰어 모드(도면 보기)를 [AI_USAGE.md](./AI_USAGE.md) §0으로 정합니다. **뷰어 모드는 §9(`SWViewer*`)만** 쓰고(예외: 창 최소화·닫기 버전의 `trailing` 끝 `SWHeaderWindowControls`, [VIEWER.md](./VIEWER.md) §2.1), 아래 §1~8은 일반 모드용입니다. 두 모드를 한 화면에 섞지 않습니다.
|
||||
- **무엇을 쓸까(일반 모드):** Header 전체 → `SWHeader`(1). Header 부품 조합 → 개별 컨트롤(2). 팝업·설정창·정보창 → `SWPopup`(3). 도구 막대·탭·토글 → `SWToolButton` / `SWToolGroup`(4). 로고 클릭 2단 메뉴·최근파일 → `SWHeader homeMenu` / `SWHomeMenu`(5). 게이지바(투명도 등)는 `SWSlider`, 선택 목록(단위 등)은 `SWSelect`(3). Switch·Badge·Progress·Dialog(모달)·Label·FilterBar는 SW에 없습니다 → `@hm-design/ui`를 그대로 씁니다(필요하면 `SWPopup` children 안에).
|
||||
- **무엇을 쓸까(일반 모드, 프로세스 단계 Header):** 로고 · 파일명 · 프로세스 단계 탭(01 Modeling → 02 Analysis Result 등) · Layer 필드 · 도구 pill · 창 이동 · 최소화 · 최대화/복원 · 종료 구성(Strana Main Header) → `SWProcessHeader`, 프로세스 영역만 → `SWProcessBar`(11). `SWHeader`와 같은 화면에 함께 두지 않습니다(Header는 화면에 하나).
|
||||
- **무엇을 쓸까(일반 모드 하단):** 하단 명령 막대(보조기능·명령 칸·분류 탭·명령어 전체보기) → `SWFooter`, 도면 공간 탭(Model/Layout) → `SWFooterLayoutTabs`, 명령창 → `SWFooterCommandLine`(10, [FOOTER.md](./FOOTER.md)).
|
||||
- **무엇을 쓸까(뷰어 모드):** Header → `SWViewerHeader`(`groups` + `trailing`), Dropdown·우클릭 → `SWViewerMenu`, 팝업·설정창·모달 → `SWViewerPanel` + `SWViewerSection`/`SWViewerField`/`SWViewerSwitch`/`SWViewerInput`/`SWViewerButton`, 옵션 패널 게이지 → `SWSlider appearance="property"`, 상태 → `SWViewerStatus`(9).
|
||||
|
||||
@@ -88,7 +89,7 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
|
||||
|
||||
| 컴포넌트 | 용도 | 주요 Props | 모양(기존 기준) | 검증 |
|
||||
|---|---|---|---|---|
|
||||
| `SWToolButton` | 버튼·토글 | `appearance`(`text` 기본 / `pill` / `document`), `pressed`(토글·선택), `children`(글자. `pill`은 접근성 이름으로만 사용), `icon`·`hoverIcon`(`pill`), `button` 속성 | `text` = 속성Bar·속성창 글자 토글(700, Hover·선택 #FFC600, 행 19px). `pill` = 인쇄·K 40 × 38(노란 Hover 링, 아이콘 24 × 23 영역). `document` = 파일명 면(48px, 글자만 Hover #FFC600). Home 바로 뒤에 두면 로고 사선 접점을 그대로 공유 | ✅ |
|
||||
| `SWToolButton` | 버튼·토글 | `appearance`(`text` 기본 / `pill` / `document`), `pressed`(토글·선택), `children`(글자. `pill`은 접근성 이름으로만 사용), `icon`·`hoverIcon`(`pill`), `button` 속성 | `text` = 속성Bar·속성창 글자 토글(700, Hover·선택 #FFC600, 행 19px). `pill` = 인쇄·K 40 × 38(노란 Hover 링, 아이콘 24 × 23 영역, `hoverIcon` 생략 시 Hover에도 기본 아이콘 유지). `document` = 파일명 면(48px, 글자만 Hover #FFC600). Home 바로 뒤에 두면 로고 사선 접점을 그대로 공유 | ✅ |
|
||||
| `SWToolGroup` | 버튼 묶음 | `appearance`(`text` 기본 = 상세보기 묶음, `pill` = 인쇄·K pill), `label`(`text`의 12px 캡션·접근성 이름), `selection`(`single` = radiogroup, 화살표·Home·End로 이동·선택), `div` 속성 | 버튼 사이 1px 구분선(`text`), 내용 폭 pill | ✅ |
|
||||
|
||||
- 탭(Model / Layout)은 별도 컴포넌트 없이 `SWToolGroup selection="single"` + `SWToolButton pressed`입니다.
|
||||
@@ -180,3 +181,24 @@ SW **뷰어 모드** 전용 Export입니다(2026-10-19 추가, 참고 DWG/DXF
|
||||
- **상수·아이콘:** `SW_FOOTER_MODEL`(`"model"`, Model 탭의 `value`), `swFooterToolIcons`(`line`·`pline`·`arc`·`circle`·`ellipse`·`rect`·`poly`·`point`·`hatch`·`ncopy` → `{ icon, activeIcon }`), `swFooterAuxIcons`(`objectSnap`·`selectionEffect`·`orthogonal`·`gridSnap`·`gridBackground`·`lineWeight`, 인라인 SVG·채움 `currentColor`).
|
||||
- **공개 타입:** `SWFooterProps`, `SWFooterSideAction`, `SWFooterSideButtonProps`, `SWFooterAuxGroupProps`, `SWFooterAuxButtonProps`, `SWFooterToolButtonProps`, `SWFooterAddSlotProps`, `SWFooterGuideProps`, `SWFooterGuideContent`, `SWFooterMenuProps`, `SWFooterMenuItem`, `SWFooterLayoutTabsProps`, `SWFooterCommandLineProps`, `SWFooterTool`(`id`, `label`, `icon`, `activeIcon`, `title`, `ariaLabel`, `disabled`), `SWFooterCategory`(`id`, `label`, `tools`, `disabled`), `SWFooterAuxItem`(`id`, `label`, `icon`, `pressed`, `title`, `disabled`), `SWFooterLayout`(`id`, `label`, `locked`, `shape`), `SWFooterToolIconName`, `SWFooterAuxIconName`.
|
||||
- **검증 표시:** Footer Export는 Storybook(`SW/Footer`)과 저장소 안 Import·Build 검증만 있고 독립 소비 프로젝트(Tarball) 브라우저 검증 기록이 없어 ◻입니다.
|
||||
|
||||
## 11. Process Header (`SWProcessHeader` · `SWProcessBar`, SW 일반 모드)
|
||||
|
||||
프로세스 단계 Header(2026-11-03 추가). Strana Main Header 디자인을 SW 일반 모드의 **독립 Header**로 옮긴 것으로, `SWHeader`·`SWViewerHeader`와 같은 층위입니다. 한 화면에는 Header 하나만 둡니다. 토큰·상태는 [DESIGN_RULES.md](./DESIGN_RULES.md) §14, 조합은 [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) 12.
|
||||
|
||||
| 컴포넌트 | 용도 | 주요 Props | 크기 | 검증 |
|
||||
|---|---|---|---|---|
|
||||
| `SWProcessHeader` | 49px Header: 로고 → 파일명 → 프로세스 영역 · Layer 면 → 창 영역(도구 pill · 창 이동 · 최소화 · 최대화/복원 · 종료) | `appearance`(`"default"` Strana · `"multi"` StrAna-SW 다중 Step, 기본 `default`, `process.appearance`가 없으면 프로세스 영역에도 적용), `documentTitle`(필수, 문서가 없으면 `""`), `documentEmptyLabel`(제목이 비었을 때 파일명 면 오른쪽 위 10px 표시, 예: `프로젝트 생성 전`), `onDocumentClick`, `logo` / `logoText`(글자 로고) / `logoSrc`(SWHeader 로고 칸: 높이 17·로고 비율 폭, 32 × 17 → 홈 80, 두 타입 공통. 글자 로고는 58px 칸 → 홈 106), `logoAlt`(기본 `logoText`), `onHomeClick`, `homeMenuOpen`(로고 메뉴는 호스트 소유), `process: SWProcessBarProps`(필수), `layer?: SWProcessHeaderLayer`(`label` 기본 `레이어`, `value`, `visible`, `open`, `disabled`), `onLayerClick`, `onLayerVisibilityChange(next)`, `tools?: ReactNode`(`SWToolButton appearance="pill"`, Header가 pill로 감쌈), `toolsLabel`(기본 `도구`), `onWindowMoveStart`, `maximized`(기본 false = 최대화 글리프, true = 복원 글리프), `onMinimize`, `onMaximizeToggle`, `onClose`, `hiddenControls?: SWProcessHeaderHideableControlId[]`(`layer`·`tools`·`windowMove`·`minimize`·`maximize`·`close`), `disabled`(프로세스·Layer만, 로고·파일명·도구·창 버튼 제외), `<header>` HTML 속성·`ref` | 높이 49. `default`: 면 48, 파일명 286 : 가운데 1260(프로세스 1003 : Layer 257) 비율, 파일명 100~286, Layer 칸 152~257, 창 영역 약 312(도구 3개). `multi`: 면 49, 파일명 189 : 프로세스 1278, 파일명 100~189, SW pill(버튼 40 × 37), 창 영역 367(도구 5개, 최대화 숨김) | ◻ |
|
||||
| `SWProcessSplitButton` | 화면 분할 도구(Strana 화면 분할, StrAna-SW 6758:12510): `tools` pill 안 버튼 + 233 × 36 선택 패널(칸 6개). 열리면 버튼이 어두운 면 + 금색 위·양옆으로 바뀌고 Header 끝까지 이어진 뒤 패널이 Header 바로 아래에 붙음 | `value: SWProcessSplitType`(필수, 버튼 아이콘·선택 칸), `onValueChange(value)`(다른 칸 선택, 패널은 열린 채), `open` / `defaultOpen` / `onOpenChange(open)`, `label`(기본 `화면 분할`), `typeLabels`, `disabled`. 키보드: Enter·Space 열기/닫기, ArrowDown 열고 선택 칸으로, 방향키·Home·End 선택, Escape 닫고 버튼으로, Tab 이탈·바깥 클릭 닫기. 실제 창 분할은 호스트 | 버튼 40 × 38(`multi` 37), 패널 233 × 36, 칸 38.5 × 34 | ◻ |
|
||||
| `SWProcessBar` | 프로세스 영역: 그룹(번호·이름·단계 탭) + 그룹 사이 화살표, 활성 그룹 발광 면, 비활성 덮개. 단계 하나만 선택(radiogroup) | `appearance`(`"default"` · `"multi"`, 기본 `default`), `groups: SWProcessGroup[]`(필수, `id`·`number`·`label`·`labelHidden`(이름을 보조기술용으로만 둠, `multi`는 제목 줄 높이 유지)·`steps: SWProcessStep[]`(`id`·`label`·`disabled`)·`disabled`), `activeStepId`, `activeGroupId`(기본 = `activeStepId`가 속한 그룹), `onStepSelect(step, group)`(이미 선택된 단계를 다시 누르면 호출 안 함), `disabled`, `aria-label`(기본 `프로세스`), `div` 속성 | `default`: 높이 48, 탭 32(넓을 때 127). `multi`: 높이 49, 제목 12px 위 + 탭 25(글자 폭 + 25, 간격 0). 그룹은 자연 폭까지·좁으면 비례 축소 | ◻ |
|
||||
|
||||
- **`default` 확정 디자인은 2그룹**(01 Modeling: Structure · Loads / 02 Analysis Result: Results)입니다. 1그룹·3그룹 이상은 렌더링되지만 디자인 미확정입니다.
|
||||
- **사다리꼴(그룹 면) 배치 확정(2026-11-03):** 시작(첫 그룹)과 끝(마지막 그룹)에만 둡니다(켜졌으면 발광 면, 아니면 비활성 면). 가운데 그룹은 켜져 있어도 도형 없이 밝은 바탕만 보입니다. 그룹 수와 관계없이, `default`·`multi` 공통입니다. 그룹 사이 화살표·비활성 덮개·선택 상태는 그대로입니다.
|
||||
- **`multi`**(Figma StrAna-SW 873:16481 · 873:16254 · 873:17421): 그룹 수·단계 수는 데이터대로이며, 켜진 그룹보다 앞 그룹은 진행 완료(제목 700 흰색·밝은 탭), 뒤 그룹은 미진행(제목 500 `#C3EBDF`·70% 탭)으로 표시합니다. 바탕·덮개·발광·화살표·선택·키보드는 `default`와 같은 구현입니다.
|
||||
- **키보드:** 탭 사이 Arrow(좌우·상하)·Home·End 이동 + 선택(비활성 단계 건너뜀), Tab은 선택된 단계 하나로만 들어갑니다.
|
||||
- **반응형:** 숨김·More·스크롤 없음. 보장 폭 1100px 이상이며 더 좁으면 Header 안에서 잘립니다. 탭 글자·파일명·Layer 이름은 `…` 말줄임. 공간이 부족하면 선택된 탭도 다른 탭과 같은 비율로 줄어듭니다(2026-11-03 개정): `default`는 모든 탭이 같은 폭, `multi`는 글자 폭에 비례하며 이름이 들어가지 않으면 `…`. 선택이 바뀌어도 탭 폭은 그대로입니다. 그룹은 이름 + 모든 탭 여백보다 좁아지지 않아 겹치지 않습니다. 그룹 사이 화살표·간격은 고정입니다. **넘침 정책 확정(2026-11-03): 그룹 접기·좌우 스크롤·More 메뉴를 추가하지 않습니다.**
|
||||
- **범위 밖(호스트 소유):** 로고 메뉴 본체, Layer Popup, 실제 화면 분할 실행, 도구 아이콘(소비 측이 `tools`에 넣는 요소). 화면 분할 선택 UI는 `SWProcessSplitButton`(2026-11-03 추가). `SWHomeMenu`는 이 Header에 쓰지 않습니다(2단 타일 디자인이 다름).
|
||||
- **SWHeader와 같은 상호작용(2026-11-03):** 창 이동은 `SWHeaderWindowControls`의 창 이동(Hover 1.4s Motion·툴팁)을 그대로 씁니다. 글자 외곽선·Hover·Focus도 SWHeader 규칙(DESIGN_RULES §14). 도구 아이콘이 이미지면 `SWToolButton`의 `hoverIcon`으로 노란 그림(`#B6D0C9 → #FFC600`)을 함께 주고, 단색 SVG면 `currentColor`로 칠합니다.
|
||||
- **단독 사용:** `SWProcessBar`만 쓸 때는 `<SWTheme>` 안에 둡니다. `SWProcessHeader`는 루트에 `hm-sw-theme`이 있어 `<SWTheme>`이 필요 없습니다.
|
||||
- **공개 타입:** `SWProcessHeaderProps`, `SWProcessHeaderLayer`, `SWProcessHeaderHideableControlId`, `SWProcessBarProps`, `SWProcessGroup`, `SWProcessStep`, `SWProcessAppearance`(`"default" | "multi"`), `SWProcessSplitType`(`single` · `columns` · `rows` · `topWide` · `leftTall` · `grid` = Strana Type01~06), `SWProcessSplitButtonProps`.
|
||||
- **검증 표시:** Storybook(`SW/Step Header`)과 저장소 안 Typecheck·Build 검증만 있고 독립 소비 프로젝트(Tarball) 브라우저 검증 기록이 없어 ◻입니다.
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 0. 어떤 패턴을 고를까
|
||||
|
||||
먼저 SW 모드를 정합니다([AI_USAGE.md](./AI_USAGE.md) §0). 1~6과 8·10은 **SW 일반 모드**, 7은 **SW 뷰어 모드**이며 한 화면에서 섞지 않습니다. 11(테마 설정)은 두 모드 공통입니다.
|
||||
먼저 SW 모드를 정합니다([AI_USAGE.md](./AI_USAGE.md) §0). 1~6과 8·10·12는 **SW 일반 모드**, 7은 **SW 뷰어 모드**이며 한 화면에서 섞지 않습니다. 11(테마 설정)은 두 모드 공통입니다.
|
||||
|
||||
| 요청 | 모드 | 패턴 |
|
||||
|---|---|---|
|
||||
@@ -18,6 +18,7 @@
|
||||
| 일반 모드 `SWHeader`에서 로고를 누르면 열리는 2단 메뉴(저장·내보내기·찾아열기·최근파일 등) | 일반 | 8. 로고 클릭 메뉴 |
|
||||
| 화면 하단 명령 막대(Line·Arc 등 명령, 분류 탭, 보조기능 토글, 명령창), Model/Layout 탭 | 일반 | 10. Footer |
|
||||
| 글꼴 등 SW 토큰을 화면 전체(Header·Dropdown·More·Panel·명령창)에 같은 값으로 바꾸기, Portal 요소 포함 | 일반·뷰어 공통 | 11. 테마(토큰) 설정 |
|
||||
| 프로세스 단계(01 Modeling → 02 Analysis Result 등) 탭이 있는 Header, Strana Main Header | 일반 | 12. 프로세스 단계 Header |
|
||||
|
||||
## 1. 완성형 SW Header — [ExampleA-DefaultHeader.tsx](./examples/ExampleA-DefaultHeader.tsx)
|
||||
|
||||
@@ -248,6 +249,49 @@ const appSWTheme = { "--hm-sw-header-font-family": '"Malgun Gothic", sans-serif'
|
||||
- 기본 글꼴은 Noto Sans KR입니다(2026-10-20 확정). 기본값을 쓸 때는 글꼴 토큰을 지정하지 않습니다. Web UI 글꼴 토큰(`--hm-font-family-base`)을 SW 화면에 넣지 않습니다.
|
||||
- `hm-sw-*` class 선택자로 토큰을 덮거나 `!important`, 컴포넌트마다 같은 값 반복 지정을 하지 않습니다.
|
||||
|
||||
## 12. 프로세스 단계 Header (일반 모드)
|
||||
|
||||
`SWHeader` 대신 쓰는 독립 Header입니다(한 화면에 Header 하나). 상태는 모두 프로그램이 갖고 Callback으로 갱신합니다. 로고 메뉴·Layer Popup·도구 Popup은 프로그램이 직접 엽니다([COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) §11).
|
||||
|
||||
```tsx
|
||||
const groups: SWProcessGroup[] = [
|
||||
{ id: "modeling", number: "01", label: "Modeling", steps: [{ id: "structure", label: "Structure" }, { id: "loads", label: "Loads" }] },
|
||||
{ id: "analysis", number: "02", label: "Analysis Result", steps: [{ id: "results", label: "Results" }] },
|
||||
]
|
||||
|
||||
<SWProcessHeader
|
||||
documentTitle={fileName} logoSrc={logoUrl} logoAlt={productName} onHomeClick={toggleFileMenu} homeMenuOpen={fileMenuOpen}
|
||||
process={{ groups, activeStepId: step, onStepSelect: s => setStep(s.id) }}
|
||||
layer={{ label: "Layer", value: layerName, visible: layerVisible, open: layerPopupOpen }}
|
||||
onLayerClick={toggleLayerPopup} onLayerVisibilityChange={setLayerVisible}
|
||||
tools={<>
|
||||
{/* ViewPropertyIcon: the program's own icon (the package ships no product icons). */}
|
||||
<SWToolButton appearance="pill" icon={<ViewPropertyIcon />} pressed={viewProperty} onClick={toggleViewProperty}>View Property</SWToolButton>
|
||||
<SWProcessSplitButton value={split} onValueChange={setSplit} open={splitOpen} onOpenChange={setSplitOpen} />
|
||||
</>}
|
||||
onWindowMoveStart={startWindowDrag} maximized={maximized}
|
||||
onMinimize={minimize} onMaximizeToggle={toggleMaximize} onClose={closeWindow} />
|
||||
```
|
||||
|
||||
1. 도구 아이콘은 프로그램이 그린 24 × 24 요소입니다. 버튼 상태 색(기본 `#B6D0C9`, Hover·눌림 `#FFC600`)을 따르려면 `currentColor`로 칠하고, 색이 고정된 이미지 아이콘이면 `hoverIcon`에 `#FFC600` 그림을 함께 줍니다(SW 인쇄 버튼과 같은 방식).
|
||||
2. 화면 분할은 `SWProcessSplitButton`을 `tools`의 원하는 자리에 둡니다(두 appearance 공통). 고른 값으로 실제 창을 나누는 일은 프로그램이 합니다. 분할 아이콘·선택 패널을 직접 그리지 않습니다.
|
||||
3. 프로세스 영역만 다른 곳에 둘 때는 `<SWTheme>` 안에 `SWProcessBar`를 둡니다.
|
||||
4. 다른 테마(Strana Dark 등)는 11처럼 가장 바깥 범위 `style`에 `--hm-sw-process-*`·`--hm-sw-header-*` 토큰을 지정합니다.
|
||||
5. `SWHeader`의 메뉴(레이어·색상·선/문자/치수스타일 Dropdown)를 이 Header에 넣지 않습니다. 필요하면 `SWHeader`를 씁니다.
|
||||
6. **그룹·단계가 많은 다중 Step 화면**(StrAna-SW Figma 873:16481 등)은 `appearance="multi"`입니다. 같은 `process` 데이터 구조를 쓰고, 제목이 없는 그룹은 `labelHidden`, 아직 열 수 없는 단계는 `disabled`, 문서가 없을 때는 `documentTitle=""` + `documentEmptyLabel`입니다. Figma 프레임에는 Layer 필드와 최대화 버튼이 없으므로 `layer`를 주지 않고 `hiddenControls={["maximize"]}`를 씁니다. 그룹·단계가 많아도 데이터를 줄이거나 접기·스크롤·More를 만들지 않습니다(넘침 정책: 선택 단계 포함 비례 축소 + `…`, 2026-11-03 개정).
|
||||
|
||||
```tsx
|
||||
<SWProcessHeader appearance="multi" documentTitle={projectName ?? ""} documentEmptyLabel="프로젝트 생성 전"
|
||||
logoSrc={logoUrl} logoAlt={productName} onHomeClick={toggleFileMenu}
|
||||
process={{ groups: [
|
||||
{ id: "data", label: "데이터 구축", steps: [{ id: "input", label: "데이터입력" }, { id: "check", label: "데이터확인" }] },
|
||||
{ id: "alignment", label: "선형 입력", labelHidden: true, steps: [{ id: "alignment", label: "선형입력" }] },
|
||||
{ id: "analysis", label: "해석 및 성과품", labelHidden: true, disabled: true, steps: [{ id: "model", label: "해석모델" }] },
|
||||
], activeStepId: step, onStepSelect: s => setStep(s.id) }}
|
||||
tools={<SWToolButton appearance="pill" icon={<img src={helpIcon} alt="" />} hoverIcon={<img src={helpIconHover} alt="" />} onClick={openHelp}>도움말</SWToolButton>}
|
||||
hiddenControls={["maximize"]} onWindowMoveStart={startWindowDrag} onMinimize={minimize} onClose={closeWindow} />
|
||||
```
|
||||
|
||||
## 9. 하지 말 것
|
||||
|
||||
- `hm-sw-*` class를 소비 코드에 쓰거나 CSS로 덮어쓰기(토큰 변경도 class 선택자가 아니라 가장 바깥 SW 범위의 `style` — 11)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
- 기본값은 가장 바깥 SW 범위에서만 선언되고 안쪽 SW 범위(Dropdown·More·Panel·명령창)는 상속합니다(2026-10-20). 값을 바꿀 때는 가장 바깥 범위의 `style`만 씁니다 — [AI_USAGE.md](./AI_USAGE.md) §3.1.
|
||||
- SW는 `@hm-design/ui` 토큰을 쓰지 않습니다. UI 패키지 토큰을 복제하거나 섞지 않습니다.
|
||||
- Light/Dark 분기 없음. 전역 선택자(`html`, `body`, `*`) 없음.
|
||||
- **모드:** §1~10과 §12·§13은 SW **일반 모드**(`SWHeader`·`SWPopup`·`SWToolButton`·`SWHomeMenu`·`SWFooter`) 기준입니다. SW **뷰어 모드**(`SWViewer*`)의 토큰·상태는 §11과 [VIEWER.md](./VIEWER.md)를 봅니다. 모드 선택·혼합 금지는 [AI_USAGE.md](./AI_USAGE.md) §0.
|
||||
- **모드:** §1~10과 §12·§13·§14는 SW **일반 모드**(`SWHeader`·`SWPopup`·`SWToolButton`·`SWHomeMenu`·`SWFooter`·`SWProcessHeader`) 기준입니다. SW **뷰어 모드**(`SWViewer*`)의 토큰·상태는 §11과 [VIEWER.md](./VIEWER.md)를 봅니다. 모드 선택·혼합 금지는 [AI_USAGE.md](./AI_USAGE.md) §0.
|
||||
|
||||
## 1. 크기·간격
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
| appearance | 기존 기준 | 상태 |
|
||||
|---|---|---|
|
||||
| `text` | 속성Bar·속성창 토글: 19px 행, 14px 700, 자간 0.04em, 묶음 위 12px 캡션, 버튼 사이 1px 구분선 | Hover·`pressed` 글자 `#FFC600`, 테두리·배경 변화 없음 |
|
||||
| `pill` | 인쇄·K: pill(`rgba(0,0,0,.8)`, 1px `#092920`, radius 4px) 안 40 × 38 버튼, 아이콘 영역 24 × 23 | Hover 노란 gradient 링 + `hoverIcon`. 아이콘 색은 기존 인쇄 아이콘 `#B6D0C9` → Hover `#FFC600`(`--hm-sw-header-selected-color`) |
|
||||
| `pill` | 인쇄·K: pill(`rgba(0,0,0,.8)`, 1px `#092920`, radius 4px) 안 40 × 38 버튼, 아이콘 영역 24 × 23 | Hover 노란 gradient 링 + `hoverIcon`(생략 시 기본 아이콘 유지). 아이콘 색은 기존 인쇄 아이콘 `#B6D0C9` → Hover `#FFC600`(`--hm-sw-header-selected-color`) |
|
||||
| `document` | 파일명 면: 48px, gradient 표면, 위·오른쪽·아래 1px gradient 테두리(`--hm-sw-header-document-border-gradient`), 글자 `#F0F7F5` 700 · 자간 0.06em | 글자만 Hover `#FFC600`. Home 바로 뒤면 로고 사선 접점 공유, 그 뒤 파일명은 왼쪽 여백 12px |
|
||||
|
||||
## 10. 새 컴포넌트 체크리스트
|
||||
@@ -214,3 +214,44 @@
|
||||
|
||||
- Figma에 없는 값: 실제 화면에서 가져온 값(바 안 명령창 배치, 좌표, ▲ 패널, 분류 ◀ ▶, 보조기능 아이콘만)과 AI 구성 값 — 디자인 확인 필요(측면 버튼 Hover 글자 `#FF5C00`·주황 테두리, 분류 행 `+` Hover, Disabled 40%, 명령창 최소 280px, 칸 영역 비율 0.57(두 실제 화면 재현 값), 보조기능 아이콘 전환 1279px, Layout 탭 이름 편집 입력의 동작).
|
||||
- 새 하단 UI는 위 토큰을 `var(--…)`로 읽고 `hm-sw-footer*` class를 쓰거나 덮어쓰지 않습니다.
|
||||
|
||||
## 14. Process Header (`SWProcessHeader` · `SWProcessBar`) — SW 일반 모드 전용
|
||||
|
||||
원본 `src/theme/sw-process-header.css`(2026-11-03). Strana Main Header(Hanmac 테마)의 값입니다. 새 토큰은 `--hm-sw-process-*`(`.hm-sw-header`, `.hm-sw-theme` 범위)이고, 값이 같은 곳은 SW Header 토큰을 그대로 읽습니다(파일명 면·테두리, 객체 면·테두리, 속성 필드 면·그림자, Hover gradient, 선택 `#FFC600`, 제목·글자, 창 이동 줄무늬, 창 버튼 글리프 `#ADB0AF`·Hover 배경). Light/Dark preset은 없으며 다른 Strana 테마는 가장 바깥 범위 `style`에 같은 토큰을 지정합니다([AI_USAGE.md](./AI_USAGE.md) §3.1).
|
||||
|
||||
| 항목 | 값 | 토큰 |
|
||||
|---|---|---|
|
||||
| 배치 | 홈·파일명 글자 = SWHeader 그대로(두 타입 공통, 2026-11-03): 홈 12 + 로고(32 × 17 → 80) + 36, 파일명은 홈과 15 겹친 뒤 2 간격(x 65), padding 5 / 12, 14px 700·자간 0.06em·SW 외곽선, 화살표 없음(글자 로고 18px 700 → 홈 106) → 프로세스 → Layer 면(객체 면, 양옆 1) → 창 영역(파일명 면 + 테두리 1px) | SW 토큰 |
|
||||
| 프로세스 바탕 | 세로 `#0C5941 → #19BF8D 10.1% → #109069 18.27% → #0C5941`, 파일명 테두리 1px, 위 모서리 6 | `--hm-sw-process-base-background`, `-base-border-gradient` |
|
||||
| 비활성 덮개 | `rgba(0, 68, 48, .902)`: 비활성 그룹, 화살표 꼭지(앞 그룹 비활성)·홈(뒤 그룹 비활성), 비활성 바깥 그룹 쪽 남는 폭 | `--hm-sw-process-dim` |
|
||||
| 그룹 글자 | 번호 12px 500 `#69C2A6`, 이름 12px(최대 56, 줄바꿈) 활성 700 `#FFF` / 비활성 500 `#C3EBDF`, 위 10.67(한 줄)·8.67(두 줄), 그룹 좌우 20, 이름 뒤 12 | `--hm-sw-process-number-color`, `-group-color`, `-group-active-color` |
|
||||
| 활성 그룹 면 | 기운 변 도형, 3층 면(85·80·45%), 발광 `drop-shadow(0 0 30px rgba(88,181,153,.85))`, 가장자리 빛 3px Blur 5, 화살표 쪽 76px에 걸쳐 사라짐. 첫 그룹 왼쪽 40px 밖, 마지막 그룹 좌우 반전·44px 밖 | `--hm-sw-process-glow-*` |
|
||||
| 비활성 바깥 그룹 면 | Blur 4 면 + `drop-shadow(0 0 15px rgba(17,105,79,.8))` + 테두리 빛 1.5px, 바깥에서 안쪽으로 사라짐(79.8→198.4 / 반전 236.7→273.1) | `--hm-sw-process-idle-*` |
|
||||
| 사다리꼴 배치(확정 2026-11-03) | 그룹 면(사다리꼴)은 프로세스 영역의 **시작(첫 그룹)과 끝(마지막 그룹)에만** 둡니다. 그 그룹이 켜져 있으면 발광 면, 아니면 비활성 면. 가운데 그룹에는 켜져 있어도 도형이 없고 밝은 바탕(덮개 없음)만 보입니다(Figma 873:17421). 그룹 수와 관계없이 같으며 `default`·`multi` 공통. 화살표·비활성 덮개·선택 상태는 그대로 |
|
||||
| 화살표 | 16 × 48. On 채움 `#27D9A5`, Dim 채움 `#ABC7BF` 25%·선 50%, 그늘이 다음 그룹 위로 20% | `--hm-sw-process-arrow-*`, `-shade*` |
|
||||
| 탭 | 높이 32, padding 0 12 2.67, 16px 700 `#F0F7F5`, 간격 1, 넓을 때 127. 모서리: 단독 16 / 첫 16 4 4 16 / 마지막 4 16 16 4 / 가운데 4 | `--hm-sw-process-tab-*` |
|
||||
| 선택 탭 폭(공통, 2026-11-03 개정) | 선택된 탭도 다른 탭과 같은 비율로 줄어듦(우선 배정 없음). 넓을 때 `default`는 127 균등 그대로. 좁으면 모든 탭이 여백 폭까지 줄고 `…`. 그룹은 이름 + 모든 탭 여백보다 좁아지지 않음(측정) | — |
|
||||
| 화면 분할 도구 | 버튼 = SW pill 버튼(Hover 링·노란 아이콘). 열림 = 면 `--hm-sw-process-tool-open-background` + 금색 위·양옆(`--hm-sw-header-hover-border-gradient`), 링 없음, 아이콘 `#FFC600`. 버튼 아래 연장(면 `#081813` + 금색 양옆)이 Header 끝까지, 패널 233 × 36(면 `#082018`, 금색 양옆·아래, 위 오른쪽·아래 radius 2, 그림자 `0 2px 4px rgba(0,0,0,.6)`)이 Header 바로 아래 버튼 왼쪽에, 버튼 오른쪽부터 패널 끝까지 금색 위 선. 칸 38.5 × 34: 밝은 틀 32 × 22(`#B6D0C9`, 검정 1px, 정수 크기 — 배율 100%에서 아래 밝은 줄 유지) 안에 그림 29.6 × 19.73 + 초록 창(`#1B6B53`, 안쪽 검정 30% 1px) + 왼쪽 위 광택, Hover·선택 = 창만 `#FFC600`. 버튼 아이콘 창 `#003425` | `--hm-sw-process-split-*`, `--hm-sw-process-tool-open-*` |
|
||||
| 탭 상태 | 기본 층 70%. Hover 글자 `#FFC600`. 선택 = 층 100%, 바탕 `--hm-sw-process-tab-active-base`, 위 5·왼쪽 6 안쪽 그림자 75%, 테두리 2px 25%, 글자 `#FFC600`. Disabled = 층 50%, 글자 `#64A491`(SW 기본 40% 대신). Focus 2px `#F0F7F5` | `--hm-sw-process-tab-*` |
|
||||
| Layer 필드 | 최대 234 × 38, 면 안 양옆 8, padding 12 / 10, 속성 필드 면 + 테두리 `#08251C → #0E3D2F → #1E7C60 → #08241C → #051612`, 전구 20 + 8 + 라벨 12/14 · 값 14/16(400). Hover·열림 = 노란 gradient 링 + 글자 `#FFC600`, Disabled 40% | `--hm-sw-process-layer-border-gradient` |
|
||||
| 도구 pill | 위·왼쪽 3, 높이 42, radius 4, 면 `rgba(0,0,0,.851)`(위 2px) → `.8`(4px), 테두리 `#092920 → #1C6F56`. 버튼 40 × 38 간격 1, 파일명 면 + 테두리 `#08251C → #208769 → #08241C → #051612`, 아이콘 24 `#B6D0C9`. Hover·눌림 = 노란 링 + 아이콘 `#FFC600` | `--hm-sw-process-pill-*`, `-tool-*` |
|
||||
| 창 이동 | SWHeader 창 이동 그대로(`SWHeaderWindowControls` 창 이동만): 88 × 48, 줄무늬 82 × 37(3, 6), 아이콘 (28, 13), Hover 1.4s Motion + 툴팁, Drag 중 정지, 해제 즉시 복원 | §6, `--hm-sw-header-stripe-color` |
|
||||
| 캡션 버튼 | 32 × 48 × 3. Hover 배경 `rgba(0,0,0,.2)` + 흰 글리프, 종료 Hover `#E40000` + 흰 글리프, Disabled 40% | `--hm-sw-header-window-*`, `--hm-sw-process-close-hover-background`, `-close-icon-hover` |
|
||||
|
||||
- 글자 외곽선은 두 appearance 모두 SW 규칙(`--hm-sw-header-text-outline` 14·16·18px, `--hm-sw-header-label-outline` 10·12px)입니다. Strana 원본(DropShadow Blur 2), multi Figma(Stroke 없음)와 다른 의도된 차이입니다(2026-11-03).
|
||||
- 도구 아이콘은 어떤 상태에서도 사라지지 않습니다: `hoverIcon`이 없는 `currentColor` 아이콘은 Hover에도 그대로 보이고 색만 `#FFC600`(`SWToolButton`은 `hoverIcon`이 있을 때만 Hover 아이콘 교체에 참여, SWHeader `tools`와 공통, 2026-11-03).
|
||||
- Hover·Focus는 SWHeader와 같습니다: 도구 버튼 노란 링 + 노란 아이콘(`hoverIcon` 또는 `currentColor`, 눌림도 같은 모양), 로고 금색 테두리, 파일명·탭 글자 `#FFC600`, 최소화·최대화 어두운 배경 + 흰 글리프, 종료 `#E40000`, Focus 2px `#F0F7F5`.
|
||||
- 파일 없음(`documentEmptyLabel`): 파일명 면 오른쪽 위 10px 700 `#AEDBCC`(`--hm-sw-process-document-empty-color`), 자간 −0.1px, 위 7px. 두 appearance 공통.
|
||||
|
||||
**`appearance="multi"`**(StrAna-SW Figma 873:16481 · 873:16254 · 873:17421, 2026-11-03). 바탕·덮개·발광·비활성 면·화살표·선택·키보드는 위 `default` 규칙과 같은 구현입니다. 아래만 다릅니다.
|
||||
|
||||
| 항목 | 값 | 토큰 |
|
||||
|---|---|---|
|
||||
| 프레임 | 면 49(아래 1px 없음), 파일명 최대 189(테두리 단색 `#0E4C3A`, 로고·글자 위치는 default·SWHeader와 같음), 프로세스 1278 비율, 창 영역 테두리 단색 | `--hm-sw-header-bar-height`, `--hm-sw-header-document-border-color` |
|
||||
| 도구 pill | SW pill 그대로(`#092920`, `rgba(0,0,0,.8)`, 안쪽 그림자) 높이 41, 위 4 · 왼쪽 5 · 오른쪽 2, 버튼 40 × 37(단색 `#08251C`) | `--hm-sw-header-pill-*` |
|
||||
| 창 이동 | SWHeader 창 이동(Motion 포함) 위치만: 줄무늬 82 × 39(3, 5), 아이콘·Motion 3px 위(28, 10) | `--hm-sw-header-stripe-color` |
|
||||
| 그룹 | 좌우 10, 위 3.8: 제목 줄 14(12px, 번호와 간격 2, 한 줄 `…`) → 1.9 → 탭 25(위 19.7). 화살표 칸 16, 그룹 내용 사이 36. 발광 면 왼쪽 50 · 반전 오른쪽 48 밖 | — |
|
||||
| 그룹 상태 | 켜진 그룹 = 제목 700 흰색, 진행 완료(켜진 그룹 앞) = 같은 제목 + 밝은 탭(바탕은 덮개), 미진행 = 제목 500 `#C3EBDF` | — |
|
||||
| 탭 | 높이 25, 글자 폭 + 25(padding 13 / 12), 14px 700, 간격 0. 모서리: 단독 50, 첫 50 0 0 50, 마지막 0 50 50 0, 가운데 0. 좁으면 선택 탭 포함 비례 축소 + `…`. 화살표 칸·그룹 사이 36 고정. 그룹 접기·좌우 스크롤·More 없음(넘침 정책 확정 2026-11-03) | — |
|
||||
| 탭 상태 | 기본(미진행) 70% `rgba(52,143,114,.8) → rgba(2,50,35,.8)` · `#0C3125`. 밝음(켜진·진행 완료) 100% `rgba(68,178,143,.8) → rgba(0,61,41,.8)` · `#0A5940`. 선택 `#113228 → #28735C`(아래 → 위) · `#001E15` · 안쪽 그림자, 글자 `#FFC600`. Disabled 50% 속성 필드 면 · `#021912`, 글자 `#7D9F91`. 공통 그림자 `1px 1px 1px rgba(31,148,113,.7)`. Hover 글자 `#FFC600`, Focus 2px `#F0F7F5` | `--hm-sw-process-multi-tab-*`, `--hm-sw-header-property-background` |
|
||||
- 새 프로세스 UI는 위 토큰을 `var(--…)`로 읽고 `hm-sw-process-*` class를 쓰거나 덮어쓰지 않습니다.
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useId } from "react"
|
||||
|
||||
/*
|
||||
* Internal art of SWProcessHeader / SWProcessBar (not exported from the package). Geometry is the Strana Main Header
|
||||
* (HeaderStyles.xaml, HEAD edda3cc); colors are --hm-sw-process-* / --hm-sw-header-* tokens, so a theme override
|
||||
* through the outermost SW scope recolors them. Inline SVG instead of asset files for that reason.
|
||||
*/
|
||||
|
||||
const stop = (token: string) => ({ stopColor: `var(${token})` })
|
||||
|
||||
/** Glow surface of the lit group (Header.ContentControl.ProcessShape). Face / halo / fade are CSS. */
|
||||
function GlowShape() {
|
||||
return (
|
||||
<>
|
||||
<span className="hm-sw-process-bar__glow-halo">
|
||||
<span className="hm-sw-process-bar__glow-face"><i /><i /><i /></span>
|
||||
</span>
|
||||
{/* Edge light: 3px stroke, 10 blur, fading at the bottom (Header.Geometry.ProcessGlowLeftEdge at -1.5). */}
|
||||
<svg className="hm-sw-process-bar__glow-edge" width="54" height="48" viewBox="0 0 54 48" overflow="visible">
|
||||
<path d="M37.6,0 L42.04,36.63 C42.53,40.65 45.94,43.66 49.98,43.66 H50.5" />
|
||||
</svg>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Idle surface of an unlit outer group (Header.ContentControl.ProcessShapeIdle). */
|
||||
function IdleShape() {
|
||||
return (
|
||||
<>
|
||||
<span className="hm-sw-process-bar__idle-halo">
|
||||
<span className="hm-sw-process-bar__idle-blur"><span className="hm-sw-process-bar__idle-face" /></span>
|
||||
</span>
|
||||
<svg className="hm-sw-process-bar__idle-rim" width="100%" height="48" overflow="visible">
|
||||
<path d="M47.153,-4.95264 C42.095,-4.95264 38.306,-0.31753 39.313,4.63925 L46.064,37.889 C46.821,41.6176 50.099,44.2971 53.904,44.2971 H60" />
|
||||
<rect x="60" y="43.5471" width="100%" height="1.5" />
|
||||
</svg>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Group surface behind the name and tabs. `mirrored`: the last group (Strana ScaleX −1, 44px outside). */
|
||||
export function SWProcessShape({ kind, mirrored }: { kind: "active" | "idle"; mirrored: boolean }) {
|
||||
return (
|
||||
<span className="hm-sw-process-bar__shape" data-kind={kind} data-mirrored={mirrored || undefined} aria-hidden="true">
|
||||
{kind === "active" ? <GlowShape /> : <IdleShape />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow between two groups (Header.Control.ProcessArrowOn / Dim, 16 x 48) and the dim cover of its cell:
|
||||
* the tip is dim while the group before is unlit, the notch while the group after is unlit.
|
||||
*/
|
||||
export function SWProcessArrow({ on, dimTip, dimNotch }: { on: boolean; dimTip: boolean; dimNotch: boolean }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<span className="hm-sw-process-bar__arrow" aria-hidden="true">
|
||||
{(dimTip || dimNotch) && (
|
||||
<svg className="hm-sw-process-bar__arrow-dim" width="16" height="48" viewBox="0 0 16 48" preserveAspectRatio="none">
|
||||
{dimTip && <path d="M0,0 L13,24 L0,48 Z" />}
|
||||
{dimNotch && <path d="M0,0 H16 V48 H0 L12.25,24 Z" />}
|
||||
</svg>
|
||||
)}
|
||||
<svg className="hm-sw-process-bar__arrow-art" width="16" height="48" viewBox="0 0 16 48" overflow="visible">
|
||||
<defs>
|
||||
<linearGradient id={`${id}s`} gradientUnits="userSpaceOnUse" x1="0.1" y1="0" x2={on ? 22.5 : 50.5} y2="0">
|
||||
<stop offset="0" style={stop("--hm-sw-process-shade")} />
|
||||
{!on && <stop offset="0.12" style={stop("--hm-sw-process-shade-mid")} />}
|
||||
<stop offset="1" style={stop("--hm-sw-process-shade-fade")} />
|
||||
</linearGradient>
|
||||
<linearGradient id={`${id}f`} x1="1" y1="0.5" x2="0" y2="0.5">
|
||||
<stop offset="0" style={stop(on ? "--hm-sw-process-arrow-fill" : "--hm-sw-process-arrow-dim-fill")} />
|
||||
<stop offset="1" style={stop("--hm-sw-process-arrow-fill-fade")} />
|
||||
</linearGradient>
|
||||
<linearGradient id={`${id}l`} x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0" style={stop("--hm-sw-process-arrow-edge")} />
|
||||
<stop offset="0.45" style={stop("--hm-sw-process-arrow-mid")} />
|
||||
<stop offset="0.7" style={stop("--hm-sw-process-arrow-light")} />
|
||||
<stop offset="1" style={stop("--hm-sw-process-arrow-edge")} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path transform="translate(3.01 0)" d="M0,0 H106.926 V47.8735 H0 L12.5175,23.9368 Z" fill={`url(#${id}s)`} opacity="0.2" />
|
||||
<path d="M11.9375,24.2236 L0,1.24 V47.2072 Z" fill={`url(#${id}f)`} opacity={on ? undefined : 0.25} />
|
||||
<g opacity={on ? undefined : 0.5} fill="none" strokeWidth="1">
|
||||
<path className="hm-sw-process-bar__arrow-shadow" transform="translate(1 0)" d="M1.285,0.2236 L13.285,24.2236 L1.285,48.2236" />
|
||||
<path d="M1.285,0.2236 L13.285,24.2236 L1.285,48.2236" stroke={`url(#${id}l)`} />
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const CAPTION_GLYPHS = {
|
||||
minimize: "M11,23 H21 V25 H11 Z",
|
||||
maximize: "M11,19 H21 V29 H11 Z M12,21 V28 H20 V21 Z",
|
||||
restore: "M13,18 H22 V27 H21 V20 H13 Z M11,21 H20 V30 H11 Z M12,23 V29 H19 V23 Z",
|
||||
close: "M16 22.5854L20.5 18.0854L21.9142 19.4996L17.4142 23.9996L21.915 28.5004L20.5008 29.9146L16 25.4138L11.4992 29.9146L10.085 28.5004L14.5858 23.9996L10.0858 19.4996L11.5 18.0854L16 22.5854Z",
|
||||
} as const
|
||||
|
||||
/** Caption glyph (Header.Geometry.Caption*) in the 32 x 48 button, filled with the button color. */
|
||||
export function SWProcessCaptionGlyph({ kind }: { kind: keyof typeof CAPTION_GLYPHS }) {
|
||||
return (
|
||||
<svg width="32" height="48" viewBox="0 0 32 48" aria-hidden="true">
|
||||
<path d={CAPTION_GLYPHS[kind]} fill="currentColor" fillRule="evenodd" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Viewport split layouts in the Strana order (ViewportSplitType Type01 … Type06). */
|
||||
export const SW_PROCESS_SPLIT_TYPES = ["single", "columns", "rows", "topWide", "leftTall", "grid"] as const
|
||||
|
||||
type Rect = readonly [x: number, y: number, width: number, height: number]
|
||||
|
||||
// Header button icon panes (Header.Geometry.ToolLayout.T1 … T5, default 2 x 2), in the 24 x 24 icon.
|
||||
const W = 6.538, H = 3.448, FW = 15.11, FH = 8.87, X1 = 4.477, X2 = 13.049, Y1 = 7.597, Y2 = 13.019
|
||||
const ICON_PANES: Record<(typeof SW_PROCESS_SPLIT_TYPES)[number], readonly Rect[]> = {
|
||||
single: [[X1, Y1, FW, FH]],
|
||||
columns: [[X1, Y1, W, FH], [X2, Y1, W, FH]],
|
||||
rows: [[X1, Y1, FW, H], [X1, Y2, FW, H]],
|
||||
topWide: [[X1, Y1, FW, H], [X1, Y2, W, H], [X2, Y2, W, H]],
|
||||
leftTall: [[X1, Y1, W, FH], [X2, Y1, W, H], [X2, Y2, W, H]],
|
||||
grid: [[X1, Y1, W, H], [X2, Y1, W, H], [X1, Y2, W, H], [X2, Y2, W, H]],
|
||||
}
|
||||
|
||||
/** Split button icon (Header.Icon.ToolLayout): the frame takes the button color (currentColor), panes stay dark. */
|
||||
export function SWProcessSplitIcon({ type }: { type: (typeof SW_PROCESS_SPLIT_TYPES)[number] }) {
|
||||
return (
|
||||
<svg className="hm-sw-process-split__icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
|
||||
<rect x="2.485" y="5.557" width="19.038" height="12.888" rx="0.481" fill="currentColor" />
|
||||
{ICON_PANES[type].map(([x, y, w, h], index) => <rect key={index} className="hm-sw-process-split__icon-pane" x={x} y={y} width={w} height={h} />)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Selector cell panes (Geometry.SplitCell.T1 … T6) in the 29.6 x 19.73 frame, radius 1.
|
||||
const CELL_PANES: Record<(typeof SW_PROCESS_SPLIT_TYPES)[number], readonly Rect[]> = {
|
||||
single: [[0.82, 0.82, 27.954, 18.088]],
|
||||
columns: [[0.82, 0.82, 13.566, 18.088], [15.2, 0.82, 13.566, 18.088]],
|
||||
rows: [[0.82, 0.82, 27.99, 8.633], [0.82, 10.26, 27.99, 8.633]],
|
||||
topWide: [[0.82, 0.82, 27.99, 8.633], [0.82, 10.26, 13.566, 8.633], [15.2, 10.26, 13.566, 8.633]],
|
||||
leftTall: [[0.82, 0.82, 13.566, 18.088], [15.2, 0.81, 13.566, 8.633], [15.2, 10.28, 13.566, 8.633]],
|
||||
grid: [[0.83, 0.81, 13.566, 8.633], [15.2, 0.81, 13.566, 8.633], [0.83, 10.26, 13.566, 8.633], [15.2, 10.28, 13.566, 8.633]],
|
||||
}
|
||||
|
||||
/**
|
||||
* Selector cell picture (Button.SplitCell): light frame with a black outline, panes with a 1px inner edge (drawn on a
|
||||
* line 0.5 inside, so the edge stays inside the pane), and the top-left sheen.
|
||||
*/
|
||||
export function SWProcessSplitCell({ type }: { type: (typeof SW_PROCESS_SPLIT_TYPES)[number] }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<span className="hm-sw-process-split__frame" aria-hidden="true">
|
||||
<svg viewBox="0 0 29.599 19.733" width="29.599" height="19.733">
|
||||
<defs>
|
||||
<linearGradient id={`${id}s`} gradientUnits="userSpaceOnUse" x1="0.2785" y1="1.8744" x2="10.2688" y2="18.0923">
|
||||
<stop offset="0" stopColor="#fff" />
|
||||
<stop offset="0.4295" stopColor="#fff" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{CELL_PANES[type].map(([x, y, w, h], index) => (
|
||||
<rect key={index} className="hm-sw-process-split__pane" x={x + 0.5} y={y + 0.5} width={w - 1} height={h - 1} rx="0.5" />
|
||||
))}
|
||||
<path transform="translate(2.18 1.9)" d="M20.1195,0 C7.69482,0 1.52955,8.77004 0,13.1551 V1 C0,0.4477 0.4477,0 1,0 H20.1195 Z" fill={`url(#${id}s)`} opacity="0.25" />
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { forwardRef, Fragment, useId, useLayoutEffect, useRef, type KeyboardEvent } from "react"
|
||||
import "../../theme/sw-process-header.css"
|
||||
import { moveRovingFocus } from "../shared/roving-focus"
|
||||
import { SWProcessArrow, SWProcessShape } from "./SWProcessArt"
|
||||
import type { SWProcessBarProps } from "./types"
|
||||
|
||||
type StepShape = "single" | "start" | "middle" | "end"
|
||||
const stepShape = (index: number, count: number): StepShape =>
|
||||
count === 1 ? "single" : index === 0 ? "start" : index === count - 1 ? "end" : "middle"
|
||||
|
||||
/**
|
||||
* Process area of the Strana Main Header (MainProcessBar.xaml) and the StrAna-SW multi step header (`appearance="multi"`):
|
||||
* groups with a number, a name and step tabs, an arrow between groups, the lit group on a glow surface. One checked step
|
||||
* for the whole bar (radio group, arrow keys move and select). Groups keep their natural width and shrink together in
|
||||
* proportion; the room left over is shared on both sides. Both appearances share this markup; only the group content
|
||||
* (name beside or above the tabs, tab look) differs in CSS. Data in props, selection through `onStepSelect`.
|
||||
*/
|
||||
export const SWProcessBar = forwardRef<HTMLDivElement, SWProcessBarProps>(function SWProcessBar(
|
||||
{ appearance = "default", groups, activeStepId, activeGroupId, onStepSelect, disabled, className, onKeyDown, "aria-label": ariaLabel, ...rest }, forwardedRef,
|
||||
) {
|
||||
const id = useId()
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const multi = appearance === "multi"
|
||||
const litGroupId = activeGroupId ?? groups.find(group => group.steps.some(step => step.id === activeStepId))?.id
|
||||
// multi only: the groups before the lit one are done (Figma 873:17421: bright name and tabs on the dim surface).
|
||||
const litIndex = groups.findIndex(group => group.id === litGroupId)
|
||||
const enabledIds = groups.flatMap(group => disabled || group.disabled ? [] : group.steps.filter(step => !step.disabled).map(step => step.id))
|
||||
const tabStop = activeStepId !== undefined && enabledIds.includes(activeStepId) ? activeStepId : enabledIds[0]
|
||||
|
||||
// Measured, so fonts and labels can change:
|
||||
// - a two-line group name sits 2px higher (Strana: 8.67 instead of 10.67);
|
||||
// - each group keeps room for its name (beside the tabs) and every tab down to its padding (the checked one too, so
|
||||
// it shrinks with the others). Groups still shrink in proportion above that minimum and never overlap below it.
|
||||
useLayoutEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
const px = (value: string) => parseFloat(value) || 0
|
||||
const update = () => {
|
||||
root.querySelectorAll<HTMLElement>(".hm-sw-process-bar__name-text").forEach(name => {
|
||||
const label = name.closest<HTMLElement>(".hm-sw-process-bar__label")
|
||||
if (label) label.dataset.lines = name.getBoundingClientRect().height > 15 ? "2" : "1"
|
||||
})
|
||||
root.querySelectorAll<HTMLElement>(".hm-sw-process-bar__group").forEach(group => {
|
||||
const style = getComputedStyle(group)
|
||||
const steps = group.querySelector<HTMLElement>(".hm-sw-process-bar__steps")
|
||||
const label = group.querySelector<HTMLElement>(".hm-sw-process-bar__label")
|
||||
if (!steps) return
|
||||
const tabs = Array.from(steps.children) as HTMLElement[]
|
||||
let min = px(style.paddingLeft) + px(style.paddingRight) + px(getComputedStyle(steps).columnGap) * Math.max(tabs.length - 1, 0)
|
||||
tabs.forEach(tab => {
|
||||
const tabStyle = getComputedStyle(tab)
|
||||
min += px(tabStyle.paddingLeft) + px(tabStyle.paddingRight) + px(tabStyle.borderLeftWidth) + px(tabStyle.borderRightWidth)
|
||||
})
|
||||
// Beside the tabs (default) the name takes its own width; above them (multi) it ellipsizes.
|
||||
if (label && style.flexDirection !== "column") min += label.offsetWidth + px(getComputedStyle(label).marginRight)
|
||||
group.style.minWidth = `${Math.ceil(min)}px`
|
||||
})
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
root.querySelectorAll(".hm-sw-process-bar__label").forEach(node => observer.observe(node))
|
||||
return () => observer.disconnect()
|
||||
}, [groups, activeStepId, appearance])
|
||||
|
||||
const keys = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented) return
|
||||
moveRovingFocus(event, Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="radio"]:not(:disabled)')), { vertical: true, select: true })
|
||||
}
|
||||
|
||||
const lit = (index: number) => groups[index]?.id === litGroupId
|
||||
const last = groups.length - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={node => {
|
||||
rootRef.current = node
|
||||
if (typeof forwardedRef === "function") forwardedRef(node)
|
||||
else if (forwardedRef) forwardedRef.current = node
|
||||
}}
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel ?? "프로세스"}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={`hm-sw-process-bar${className ? ` ${className}` : ""}`}
|
||||
data-appearance={multi ? "multi" : undefined}
|
||||
onKeyDown={keys}
|
||||
{...rest}
|
||||
>
|
||||
<span className="hm-sw-process-bar__rest" data-side="start" data-dim={!lit(0) || undefined} aria-hidden="true" />
|
||||
{groups.map((group, index) => {
|
||||
const active = lit(index)
|
||||
const outer = index === 0 || index === last
|
||||
const labelId = `${id}-group-${index}`
|
||||
return (
|
||||
<Fragment key={group.id}>
|
||||
{index > 0 && <SWProcessArrow on={lit(index - 1)} dimTip={!lit(index - 1)} dimNotch={!active} />}
|
||||
<div className="hm-sw-process-bar__group" data-active={active || undefined} data-dim={!active || undefined}
|
||||
data-progress={multi && litIndex >= 0 && index < litIndex ? "done" : undefined}>
|
||||
{/* Trapezoids only at the start and the end of the area (glow when that group is lit, idle otherwise);
|
||||
a lit middle group has the bright base without a shape (Figma 873:17421). */}
|
||||
{outer && <SWProcessShape kind={active ? "active" : "idle"} mirrored={index === last && index > 0} />}
|
||||
<span className="hm-sw-process-bar__label" id={labelId} data-label-hidden={group.labelHidden || undefined}>
|
||||
{group.number !== undefined && <span className="hm-sw-process-bar__number">{group.number}</span>}
|
||||
<span className="hm-sw-process-bar__name">
|
||||
{/* The place is always the 700 width, so the bar never moves when the group lights up. */}
|
||||
<span className="hm-sw-process-bar__name-reserve" aria-hidden="true">{group.label}</span>
|
||||
<span className="hm-sw-process-bar__name-text">{group.label}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hm-sw-process-bar__steps">
|
||||
{group.steps.map((step, stepIndex) => {
|
||||
const checked = step.id === activeStepId
|
||||
const off = disabled || group.disabled || step.disabled
|
||||
return (
|
||||
<button
|
||||
key={step.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
className="hm-sw-process-bar__step"
|
||||
data-shape={stepShape(stepIndex, group.steps.length)}
|
||||
aria-checked={checked}
|
||||
aria-label={step.label}
|
||||
aria-describedby={labelId}
|
||||
tabIndex={step.id === tabStop ? 0 : -1}
|
||||
disabled={off}
|
||||
onClick={() => { if (!checked) onStepSelect?.(step, group) }}
|
||||
>
|
||||
<span className="hm-sw-process-bar__step-box" aria-hidden="true">
|
||||
<i data-layer="glow" /><i data-layer="glow2" /><i data-layer="depth" /><i data-layer="shade" /><i data-layer="sheen" />
|
||||
<i data-layer="frame" /><i data-layer="inset-top" /><i data-layer="inset-left" /><i data-layer="ring" />
|
||||
</span>
|
||||
<span className="hm-sw-process-bar__step-text">{step.label}</span>
|
||||
<span className="hm-sw-process-bar__step-size" aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
<span className="hm-sw-process-bar__rest" data-side="end" data-dim={!lit(last) || undefined} aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { forwardRef } from "react"
|
||||
import "../../theme/sw-process-header.css"
|
||||
import { SWHeaderDocumentTitle } from "../sw-header/components/SWHeaderDocumentTitle"
|
||||
import { SWHeaderHome } from "../sw-header/components/SWHeaderHome"
|
||||
import { SWHeaderLayerToggle } from "../sw-header/components/SWHeaderLayerIcons"
|
||||
import { SWHeaderWindowControls } from "../sw-header/components/SWHeaderWindowControls"
|
||||
import { SWToolGroup } from "../sw-tool/SWTool"
|
||||
import { SWProcessBar } from "./SWProcessBar"
|
||||
import { SWProcessCaptionGlyph } from "./SWProcessArt"
|
||||
import type { SWProcessHeaderLayer, SWProcessHeaderProps } from "./types"
|
||||
|
||||
/** Layer field: bulb (the SWHeader layer toggle) and the field button as siblings, one look (Strana LayerField). */
|
||||
function SWProcessLayerField({ layer, disabled, onClick, onVisibilityChange }: {
|
||||
layer: SWProcessHeaderLayer
|
||||
disabled: boolean
|
||||
onClick?: () => void
|
||||
onVisibilityChange?: (nextVisible: boolean) => void
|
||||
}) {
|
||||
const label = layer.label ?? "레이어"
|
||||
return (
|
||||
<div className="hm-sw-process-header__layer-field" data-open={layer.open || undefined} data-disabled={disabled || undefined}>
|
||||
{/* Bulb first: the tab order follows what is seen; the field button lies under it (z-index). */}
|
||||
<SWHeaderLayerToggle kind="visibility" layer={{ id: "layer", name: layer.value, visible: layer.visible ?? true, disabled }}
|
||||
onChange={onVisibilityChange ? (_layer, next) => onVisibilityChange(next) : undefined} />
|
||||
<button type="button" className="hm-sw-process-header__layer-button" title={layer.value} aria-label={`${label}: ${layer.value}`}
|
||||
aria-expanded={layer.open} disabled={disabled} onClick={onClick} />
|
||||
<span className="hm-sw-process-header__layer-text" aria-hidden="true">
|
||||
<span className="hm-sw-process-header__layer-label">{label}</span>
|
||||
<span className="hm-sw-process-header__layer-value">{layer.value}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SW general-mode header of the Strana Main Header (49px): logo → file name → process area and layer surface →
|
||||
* window area (tool pill, the SWHeader window move with its hover motion, minimize, maximize / restore, close). An independent header
|
||||
* beside SWHeader and SWViewerHeader; one header per screen. Every state is a prop and every action a callback; the
|
||||
* logo menu, the layer popup and the tool popups belong to the host. Reuses SWHeaderHome, SWHeaderDocumentTitle,
|
||||
* the SWHeader layer bulb and the SWToolGroup pill; the rest is sw-process-header.css. `appearance="multi"` is the
|
||||
* StrAna-SW multi step design on the same markup (CSS only: frame sizes, file name, tool pill, process group content).
|
||||
*/
|
||||
export const SWProcessHeader = forwardRef<HTMLElement, SWProcessHeaderProps>(function SWProcessHeader({
|
||||
appearance = "default", documentTitle, documentEmptyLabel, onDocumentClick, logo, logoText, logoSrc, logoAlt, onHomeClick, homeMenuOpen,
|
||||
process, layer, onLayerClick, onLayerVisibilityChange, tools, toolsLabel = "도구",
|
||||
onWindowMoveStart, maximized = false, onMinimize, onMaximizeToggle, onClose, hiddenControls, disabled = false,
|
||||
className, ...rest
|
||||
}, ref) {
|
||||
const hidden = new Set(hiddenControls ?? [])
|
||||
const logoNode = logo ?? (logoText ? <span className="hm-sw-process-header__wordmark">{logoText}</span> : undefined)
|
||||
const toolsShown = tools !== undefined && tools !== null && tools !== false && !hidden.has("tools")
|
||||
const captions = (["minimize", "maximize", "close"] as const).filter(id => !hidden.has(id))
|
||||
const windowShown = toolsShown || !hidden.has("windowMove") || captions.length > 0
|
||||
const documentEmpty = documentTitle === "" && !!documentEmptyLabel
|
||||
|
||||
return (
|
||||
<header ref={ref} className={`hm-sw-theme hm-sw-process-header${className ? ` ${className}` : ""}`}
|
||||
data-appearance={appearance === "multi" ? "multi" : undefined} data-document-empty={documentEmpty || undefined} {...rest}>
|
||||
<div className="hm-sw-process-header__bar">
|
||||
<SWHeaderHome logo={logoNode} logoSrc={logoSrc} logoAlt={logoAlt ?? logoText} onClick={onHomeClick} aria-expanded={homeMenuOpen} />
|
||||
<SWHeaderDocumentTitle title={documentEmpty ? documentEmptyLabel : documentTitle} onClick={onDocumentClick} />
|
||||
<div className="hm-sw-process-header__center">
|
||||
<SWProcessBar appearance={appearance} {...process} disabled={disabled || process.disabled} />
|
||||
{layer && !hidden.has("layer") && (
|
||||
<div className="hm-sw-process-header__layer">
|
||||
<SWProcessLayerField layer={layer} disabled={disabled || !!layer.disabled} onClick={onLayerClick} onVisibilityChange={onLayerVisibilityChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{windowShown && (
|
||||
<div className="hm-sw-process-header__window">
|
||||
{toolsShown && <SWToolGroup appearance="pill" aria-label={toolsLabel} className="hm-sw-process-header__tools">{tools}</SWToolGroup>}
|
||||
{/* The SWHeader window move as it is: stripes, icon, 1.4s hover motion and tooltip (minimize / close are ours). */}
|
||||
{!hidden.has("windowMove") && <SWHeaderWindowControls onWindowMoveStart={onWindowMoveStart} showMinimize={false} showClose={false} />}
|
||||
{captions.map(id => (
|
||||
<button key={id} type="button" className="hm-sw-process-header__caption" data-kind={id}
|
||||
aria-label={id === "minimize" ? "최소화" : id === "close" ? "종료" : maximized ? "이전 크기로 복원" : "최대화"}
|
||||
onClick={id === "minimize" ? onMinimize : id === "close" ? onClose : onMaximizeToggle}>
|
||||
<SWProcessCaptionGlyph kind={id === "maximize" && maximized ? "restore" : id} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { forwardRef, useEffect, useId, useRef, useState, type FocusEvent, type KeyboardEvent } from "react"
|
||||
import "../../theme/sw-process-header.css"
|
||||
import { moveRovingFocus } from "../shared/roving-focus"
|
||||
import { useOutsidePointerDown } from "../shared/use-outside-pointer"
|
||||
import { useSWFixedDropdownPosition } from "../sw-header/components/SWHeaderDropdown"
|
||||
import { SWToolButton } from "../sw-tool/SWTool"
|
||||
import { SW_PROCESS_SPLIT_TYPES, SWProcessSplitCell, SWProcessSplitIcon } from "./SWProcessArt"
|
||||
import type { SWProcessSplitButtonProps, SWProcessSplitType } from "./types"
|
||||
|
||||
const TYPE_LABELS: Record<SWProcessSplitType, string> = {
|
||||
single: "1면", columns: "좌우 2분할", rows: "상하 2분할", topWide: "상단 1면 + 하단 2분할", leftTall: "좌측 1면 + 우측 2분할", grid: "4분할",
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport split tool for the `tools` pill of SWProcessHeader (both appearances). The button is the SW pill button
|
||||
* (hover ring, focus, disabled); open, it takes the Strana open look and the selector hangs from it: a 3px extension of
|
||||
* the button, the 233 x 36 panel under its left edge and a gold line from its right edge to the panel end. The selector
|
||||
* is a fixed overlay kept on screen (the SW field dropdown position), so the header clipping never cuts it.
|
||||
* Keyboard: Enter / Space toggle, ArrowDown opens on the checked cell, arrows / Home / End move and choose (radio group),
|
||||
* Escape closes back to the button, Tab out and a click outside close.
|
||||
*/
|
||||
export const SWProcessSplitButton = forwardRef<HTMLButtonElement, SWProcessSplitButtonProps>(function SWProcessSplitButton(
|
||||
{ value, onValueChange, open: openProp, defaultOpen = false, onOpenChange, label = "화면 분할", typeLabels, disabled = false }, forwardedRef,
|
||||
) {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen)
|
||||
const open = !disabled && (openProp ?? internalOpen)
|
||||
const rootRef = useRef<HTMLSpanElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const popupRef = useRef<HTMLDivElement>(null)
|
||||
const focusCellRef = useRef(false)
|
||||
const popupId = useId()
|
||||
|
||||
const setOpen = (next: boolean) => {
|
||||
if (openProp === undefined) setInternalOpen(next)
|
||||
onOpenChange?.(next)
|
||||
}
|
||||
const close = (restoreFocus: boolean) => {
|
||||
setOpen(false)
|
||||
if (restoreFocus) triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
useOutsidePointerDown(open, rootRef, () => setOpen(false))
|
||||
const position = useSWFixedDropdownPosition(open, triggerRef, popupRef, "start")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !focusCellRef.current || !position) return
|
||||
focusCellRef.current = false
|
||||
popupRef.current?.querySelector<HTMLButtonElement>('[role="radio"][tabindex="0"]')?.focus()
|
||||
})
|
||||
|
||||
// The button extension reaches the header bottom, so the panel starts under the header and never covers the
|
||||
// window buttons next to the pill (Strana 3px; at least 3px when used outside a header).
|
||||
const trigger = triggerRef.current?.getBoundingClientRect()
|
||||
const headerBottom = triggerRef.current?.closest(".hm-sw-process-header")?.getBoundingClientRect().bottom
|
||||
const tail = open && position && trigger && headerBottom !== undefined ? Math.max(3, Math.round(headerBottom - trigger.bottom + 1)) : 3
|
||||
// The selector hangs from the button (tail), so it never takes the hook's flip above the field: flipped, it was
|
||||
// drawn over the header with its panel bottom cut. With no room below it stays under the button; the hook follows
|
||||
// the page scroll, which brings it into view.
|
||||
const top = position && trigger ? (position.flipped ? trigger.bottom : position.top) - 1 : 0
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key === "Escape" && open) {
|
||||
event.stopPropagation()
|
||||
close(true)
|
||||
return
|
||||
}
|
||||
if (event.target === triggerRef.current) {
|
||||
if (event.key === "ArrowDown" && !open) {
|
||||
event.preventDefault()
|
||||
focusCellRef.current = true
|
||||
setOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
moveRovingFocus(event, Array.from(popupRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]') ?? []), { vertical: true, select: true })
|
||||
}
|
||||
const onBlur = (event: FocusEvent<HTMLSpanElement>) => {
|
||||
if (open && !rootRef.current?.contains(event.relatedTarget as Node | null)) setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<span ref={rootRef} className="hm-sw-process-split" data-open={open || undefined} onKeyDown={onKeyDown} onBlur={onBlur}>
|
||||
<SWToolButton
|
||||
ref={node => {
|
||||
triggerRef.current = node
|
||||
if (typeof forwardedRef === "function") forwardedRef(node)
|
||||
else if (forwardedRef) forwardedRef.current = node
|
||||
}}
|
||||
appearance="pill"
|
||||
icon={<SWProcessSplitIcon type={value} />}
|
||||
title={label}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? popupId : undefined}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{label}
|
||||
</SWToolButton>
|
||||
{open && (
|
||||
<div
|
||||
ref={popupRef}
|
||||
id={popupId}
|
||||
className="hm-sw-process-split__popup"
|
||||
style={position
|
||||
? { left: position.left, top, ["--hm-sw-process-split-offset" as string]: `${-position.offset}px`, ["--hm-sw-process-split-tail" as string]: `${tail}px` }
|
||||
: { visibility: "hidden" }}
|
||||
>
|
||||
<span className="hm-sw-process-split__tail" aria-hidden="true" />
|
||||
<div className="hm-sw-process-split__panel" role="radiogroup" aria-label={label}>
|
||||
{SW_PROCESS_SPLIT_TYPES.map(type => {
|
||||
const checked = type === value
|
||||
return (
|
||||
<button key={type} type="button" role="radio" className="hm-sw-process-split__cell" aria-checked={checked}
|
||||
aria-label={typeLabels?.[type] ?? TYPE_LABELS[type]} tabIndex={checked ? 0 : -1}
|
||||
onClick={() => { if (!checked) onValueChange?.(type) }}>
|
||||
<SWProcessSplitCell type={type} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span className="hm-sw-process-split__step" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export { SWProcessHeader } from "./SWProcessHeader"
|
||||
export { SWProcessBar } from "./SWProcessBar"
|
||||
export { SWProcessSplitButton } from "./SWProcessSplitButton"
|
||||
export type {
|
||||
SWProcessHeaderProps, SWProcessHeaderLayer, SWProcessHeaderHideableControlId,
|
||||
SWProcessBarProps, SWProcessGroup, SWProcessStep, SWProcessAppearance, SWProcessSplitType, SWProcessSplitButtonProps,
|
||||
} from "./types"
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { HTMLAttributes, PointerEventHandler, ReactNode } from "react"
|
||||
|
||||
/** One tab of a process group (e.g. `Structure`). */
|
||||
export interface SWProcessStep {
|
||||
/** Stable key, returned with the selection. */
|
||||
id: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Process area design (2026-11-03).
|
||||
* - `default`: the Strana Main Header — name beside 32px tabs of equal width (127 at most), two groups confirmed.
|
||||
* - `multi`: StrAna-SW multi step (Figma 873:16481 / 873:16254 / 873:17421) — title above 25px tabs as wide as their
|
||||
* label, any number of groups; groups before the lit one show as done.
|
||||
* Both share the area surface, dim covers, group surfaces, arrows, selection and keyboard.
|
||||
*/
|
||||
export type SWProcessAppearance = "default" | "multi"
|
||||
|
||||
/**
|
||||
* One group of the process area (e.g. `01 Modeling`). `default` confirms two groups; `multi` takes any count. In both,
|
||||
* the group surfaces (trapezoids) stand only at the start and the end of the area — never on a middle group, lit or not.
|
||||
*/
|
||||
export interface SWProcessGroup {
|
||||
id: string
|
||||
/** Number shown before the name, e.g. `01`. */
|
||||
number?: string
|
||||
/** Group name: `default` wraps it at 56px (two lines move it 2px up), `multi` keeps one line above the tabs. */
|
||||
label: string
|
||||
/**
|
||||
* The name stays for assistive technology only (each step is described by it). `multi` keeps its row, so the tabs
|
||||
* line up with the other groups (Figma groups without a title). Default false.
|
||||
*/
|
||||
labelHidden?: boolean
|
||||
steps: readonly SWProcessStep[]
|
||||
/** Disables every step of the group. */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SWProcessBarProps extends Omit<HTMLAttributes<HTMLDivElement>, "children" | "onChange"> {
|
||||
/** Design of the area. Default `default`. */
|
||||
appearance?: SWProcessAppearance
|
||||
groups: readonly SWProcessGroup[]
|
||||
/** The checked step (one for the whole bar). None checked when omitted. */
|
||||
activeStepId?: string
|
||||
/** The lit group (glow surface, bright text). Defaults to the group of `activeStepId`. */
|
||||
activeGroupId?: string
|
||||
/** A step other than the checked one was chosen (click, or arrow keys). The host updates `activeStepId`. */
|
||||
onStepSelect?: (step: SWProcessStep, group: SWProcessGroup) => void
|
||||
/** Disables every step. */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** Layer field of the header (bulb + label + current layer). The host owns the layer data and its popup. */
|
||||
export interface SWProcessHeaderLayer {
|
||||
/** Field label. Default `레이어`. */
|
||||
label?: string
|
||||
/** Current layer name (ellipsis when narrow). */
|
||||
value: string
|
||||
/** Bulb on (colored) when true. Default true. */
|
||||
visible?: boolean
|
||||
/** The host popup is open: the field shows its open look (yellow ring and text) and `aria-expanded`. */
|
||||
open?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** Parts that can be removed. Removed parts take no space; the logo, file name and process area always show. */
|
||||
export type SWProcessHeaderHideableControlId = "layer" | "tools" | "windowMove" | "minimize" | "maximize" | "close"
|
||||
|
||||
export interface SWProcessHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title" | "children"> {
|
||||
/**
|
||||
* Header design, also used by the process area unless `process.appearance` is set. `default`: Strana Main Header
|
||||
* (48px surfaces, layer field, Strana tool pill). `multi`: StrAna-SW multi step (49px surfaces, 189px file name,
|
||||
* SW tool pill 40 x 37). Logo and file-name text follow SWHeader in both. Default `default`.
|
||||
*/
|
||||
appearance?: SWProcessAppearance
|
||||
/** File name in the document area (ellipsis when narrow, full name as tooltip). Empty while no document is open. */
|
||||
documentTitle: string
|
||||
/**
|
||||
* Shown instead of the file name while `documentTitle` is empty (e.g. `프로젝트 생성 전`): 10px 700 at the top right
|
||||
* of the file-name surface (Figma 873:16481). Still the same button (`onDocumentClick`).
|
||||
*/
|
||||
documentEmptyLabel?: string
|
||||
onDocumentClick?: () => void
|
||||
/** Logo element in the SWHeader logo slot (17px high, as wide as the logo: 32 x 17 -> home 80px). Takes precedence over `logoText` and `logoSrc`. */
|
||||
logo?: ReactNode
|
||||
/** Text logo (e.g. `StrAna`): 18px 700 with the SW text outline in a 58px place (home 106px). */
|
||||
logoText?: string
|
||||
/** Logo image URL, contained (ratio kept, centered) in the logo slot. */
|
||||
logoSrc?: string
|
||||
/** Accessible product name; the logo button is announced as "<logoAlt> 홈". Defaults to `logoText`. */
|
||||
logoAlt?: string
|
||||
/** Logo click. The menu it opens belongs to the host. */
|
||||
onHomeClick?: () => void
|
||||
/** The host logo menu is open: the logo keeps its hover look and `aria-expanded`. */
|
||||
homeMenuOpen?: boolean
|
||||
/** Process area data and selection (`SWProcessBar` props). */
|
||||
process: SWProcessBarProps
|
||||
/** Layer field. Omitted: no layer surface. */
|
||||
layer?: SWProcessHeaderLayer
|
||||
/** Layer field click (open the host layer popup and set `layer.open`). */
|
||||
onLayerClick?: () => void
|
||||
/** Bulb click. Without it the bulb is a status icon only. */
|
||||
onLayerVisibilityChange?: (nextVisible: boolean) => void
|
||||
/** Tool buttons of the dark pill, e.g. `SWToolButton appearance="pill"` (40 x 38 each, `multi` 40 x 37, 1px apart). */
|
||||
tools?: ReactNode
|
||||
/** Accessible name of the tool pill. Default `도구`. */
|
||||
toolsLabel?: string
|
||||
/** Pointer down on the window-move area (the host moves the window). */
|
||||
onWindowMoveStart?: PointerEventHandler<HTMLDivElement>
|
||||
/** Window maximized: the maximize button shows the restore glyph. Default false. */
|
||||
maximized?: boolean
|
||||
onMinimize?: () => void
|
||||
/** Maximize / restore button. The host toggles the window and updates `maximized`. */
|
||||
onMaximizeToggle?: () => void
|
||||
onClose?: () => void
|
||||
/** Parts to remove. Default: none. */
|
||||
hiddenControls?: readonly SWProcessHeaderHideableControlId[]
|
||||
/** Disables the process area and the layer field (logo, file name, host tools and window buttons stay). */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport split layout (Strana ViewportSplitType, in this order): `single` Type01 one pane, `columns` Type02 left /
|
||||
* right, `rows` Type03 top / bottom, `topWide` Type04 one top + two bottom, `leftTall` Type05 one left + two right,
|
||||
* `grid` Type06 2 x 2.
|
||||
*/
|
||||
export type SWProcessSplitType = "single" | "columns" | "rows" | "topWide" | "leftTall" | "grid"
|
||||
|
||||
/**
|
||||
* Viewport split tool of the process header pill (Strana Main Header 화면 분할, StrAna-SW 6758:12510): a pill button
|
||||
* showing the current layout and, under it, a 233 x 36 selector of the six layouts. Choosing a layout only reports it;
|
||||
* splitting the program window is the host's job.
|
||||
*/
|
||||
export interface SWProcessSplitButtonProps {
|
||||
/** Current layout: the button icon and the checked cell. */
|
||||
value: SWProcessSplitType
|
||||
/** A cell other than the current one was chosen (click, or arrow keys). The selector stays open, like Strana. */
|
||||
onValueChange?: (value: SWProcessSplitType) => void
|
||||
/** Controlled open state of the selector; uncontrolled when undefined. */
|
||||
open?: boolean
|
||||
/** Uncontrolled initial state. Default false. */
|
||||
defaultOpen?: boolean
|
||||
/** The selector opened or closed (button, Escape, a click outside, Tab out). */
|
||||
onOpenChange?: (open: boolean) => void
|
||||
/** Accessible name of the button and the selector. Default `화면 분할`. */
|
||||
label?: string
|
||||
/** Accessible names of the cells. Defaults: 1면, 좌우 2분할, 상하 2분할, 상단 1면 + 하단 2분할, 좌측 1면 + 우측 2분할, 4분할. */
|
||||
typeLabels?: Partial<Record<SWProcessSplitType, string>>
|
||||
disabled?: boolean
|
||||
}
|
||||
@@ -48,7 +48,8 @@ export const SWToolButton = forwardRef<HTMLButtonElement, SWToolButtonProps>(fun
|
||||
if (appearance === "pill") {
|
||||
return (
|
||||
<button ref={ref} type={type} className={classes("hm-sw-header__action hm-sw-header__action--print hm-sw-tool__pill-button")} data-selected={pressed ? "true" : undefined} {...state} {...rest}>
|
||||
{icon !== undefined && <span className="hm-sw-header__icon hm-sw-tool__icon" aria-hidden="true">{icon}</span>}
|
||||
{/* hm-sw-header__icon is the "hide on hover" half of the sw.css icon swap: only with a hover picture to show. */}
|
||||
{icon !== undefined && <span className={hoverIcon !== undefined ? "hm-sw-header__icon hm-sw-tool__icon" : "hm-sw-tool__icon"} aria-hidden="true">{icon}</span>}
|
||||
{hoverIcon !== undefined && <span className="hm-sw-header__icon-hover hm-sw-tool__icon" aria-hidden="true">{hoverIcon}</span>}
|
||||
{children !== undefined && <span className="hm-sw-tool__label">{children}</span>}
|
||||
</button>
|
||||
|
||||
@@ -21,3 +21,6 @@ export * from "./components/sw-viewer"
|
||||
|
||||
// SW footer (2026-10-20): Figma 26:10097 bottom bar, command slots, category row, layout tabs and command line.
|
||||
export * from "./components/sw-footer"
|
||||
|
||||
// SW process header (2026-11-03): the Strana Main Header as an independent SW general-mode header and its process area.
|
||||
export * from "./components/sw-process-header"
|
||||
@@ -0,0 +1,645 @@
|
||||
/* @hm-design/sw — SW Process Header (Strana Main Header, D:\Strana\Git_StranaDevDesign HEAD edda3cc: HeaderStyles.xaml,
|
||||
HeaderBrushes.xaml, MainProcessBar.xaml, Hanmac Colors.xaml; 2026-11-03). SW general mode, an independent header
|
||||
beside SWHeader / SWViewerHeader. Only --hm-sw-process-* tokens and hm-sw-process-* classes are new; values equal to
|
||||
an SW token read that token. Never touches global selectors or SWHeader rules; the reused SW parts (home, file name,
|
||||
tool pill) are adjusted only inside .hm-sw-process-header. Declared on the outermost SW scope only, like sw.css. */
|
||||
|
||||
:is(.hm-sw-header, .hm-sw-theme):not(:where(.hm-sw-header, .hm-sw-theme) *) {
|
||||
/* Process area base (Header.Border.ProcessBase): ProcessBase gradient, DocBorder 1px, top corners 6. */
|
||||
--hm-sw-process-base-background: linear-gradient(180deg, #0c5941 0%, #19bf8d 10.1%, #109069 18.27%, #0c5941 100%);
|
||||
--hm-sw-process-base-border-gradient: var(--hm-sw-header-document-border-gradient);
|
||||
/* Cover over an unlit group, its arrow side and its outer rest (ProcessDim #E6004430). */
|
||||
--hm-sw-process-dim: rgba(0, 68, 48, 0.902);
|
||||
|
||||
/* Group number and name (12px, line 14). */
|
||||
--hm-sw-process-number-color: #69c2a6;
|
||||
--hm-sw-process-group-color: #c3ebdf;
|
||||
--hm-sw-process-group-active-color: #fff;
|
||||
|
||||
/* Lit group surface (ProcessGlowFace: three layer groups at 85 / 80 / 45%, over the shape bounds -4.95 .. 44.16),
|
||||
halo (DropShadow 30, 85%) and edge light (3px, Blur 10, 65%). */
|
||||
--hm-sw-process-glow-face-base: linear-gradient(0deg, rgba(24, 134, 101, 0.702) 0%, rgba(24, 134, 101, 0) 40%), linear-gradient(180deg, #063b2b 0%, rgba(13, 101, 74, 0.051) 96.03%);
|
||||
--hm-sw-process-glow-face-rim: linear-gradient(180deg, rgba(13, 101, 74, 0.051) 87.76%, #247a60 96.05%);
|
||||
--hm-sw-process-glow-face-sheen: linear-gradient(rgba(0, 95, 66, 0.251), rgba(0, 95, 66, 0.251)), linear-gradient(180deg, #20322c 6.94%, #3dc79d 21.41%, rgba(61, 199, 157, 0.051) 96.05%);
|
||||
--hm-sw-process-glow-halo: rgba(88, 181, 153, 0.85);
|
||||
--hm-sw-process-glow-edge: rgba(88, 181, 153, 0.65);
|
||||
/* Unlit outer group surface (ProcessShapeIdleFace, Blur 8, DropShadow 15 #11694F 80%, rim 1.5px Blur 2). */
|
||||
--hm-sw-process-idle-face: linear-gradient(180deg, rgba(0, 0, 0, 0.502) 10.08%, rgba(0, 0, 0, 0) 26.37%), linear-gradient(180deg, rgba(23, 42, 26, 0.251) 0%, rgba(23, 42, 26, 0.251) 62%, rgba(0, 0, 0, 0) 90%);
|
||||
--hm-sw-process-idle-halo: rgba(17, 105, 79, 0.8);
|
||||
--hm-sw-process-idle-rim: rgba(4, 128, 90, 0.322);
|
||||
|
||||
/* Arrow between groups (16 x 48): fill, stroke stops, line shadow, shade over the next group. */
|
||||
--hm-sw-process-arrow-fill: #27d9a5;
|
||||
--hm-sw-process-arrow-dim-fill: #abc7bf;
|
||||
--hm-sw-process-arrow-fill-fade: rgba(16, 109, 81, 0);
|
||||
--hm-sw-process-arrow-edge: #00432e;
|
||||
--hm-sw-process-arrow-mid: #1c6a51;
|
||||
--hm-sw-process-arrow-light: #3d9c7e;
|
||||
--hm-sw-process-arrow-shadow: rgba(0, 0, 0, 0.502);
|
||||
--hm-sw-process-shade: rgba(4, 34, 25, 0.878);
|
||||
--hm-sw-process-shade-mid: rgba(4, 34, 25, 0.706);
|
||||
--hm-sw-process-shade-fade: rgba(4, 34, 25, 0);
|
||||
|
||||
/* Step tab (Header.RadioButton.ProcessTab, 32px): stacked layers, 70% unchecked / 100% checked / 50% disabled. */
|
||||
--hm-sw-process-tab-text: var(--hm-sw-header-title-color);
|
||||
--hm-sw-process-tab-glow: rgba(31, 148, 113, 0.702);
|
||||
--hm-sw-process-tab-depth: linear-gradient(180deg, rgba(0, 61, 41, 0) 0%, rgba(0, 61, 41, 0.8) 100%);
|
||||
--hm-sw-process-tab-shade: linear-gradient(0deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0) 25%);
|
||||
--hm-sw-process-tab-sheen: linear-gradient(180deg, rgba(74, 255, 196, 0.502) 0%, rgba(74, 255, 196, 0) 40%);
|
||||
--hm-sw-process-tab-border-gradient: linear-gradient(180deg, #001e15 8.98%, #17a77b 22.9%, #0b533d 100%);
|
||||
--hm-sw-process-tab-ring: rgba(0, 0, 0, 0.251);
|
||||
--hm-sw-process-tab-active-base: linear-gradient(0deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.4) 26.41%, rgba(0, 0, 0, 0.451) 70%, rgba(0, 0, 0, 0.4) 100%);
|
||||
--hm-sw-process-tab-active-shade: linear-gradient(180deg, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0) 80%);
|
||||
--hm-sw-process-tab-active-sheen: linear-gradient(180deg, rgba(122, 206, 179, 0) 60%, rgba(122, 206, 179, 0.102) 100%);
|
||||
--hm-sw-process-tab-inset: rgba(0, 0, 0, 0.659);
|
||||
--hm-sw-process-tab-disabled-shade: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.8) 100%);
|
||||
--hm-sw-process-tab-disabled-sheen: linear-gradient(180deg, rgba(130, 181, 164, 0.502) 0%, rgba(122, 206, 179, 0) 40%);
|
||||
--hm-sw-process-tab-disabled-text: #64a491;
|
||||
|
||||
/* Header frame parts that differ from SWHeader. */
|
||||
--hm-sw-process-layer-border-gradient: linear-gradient(180deg, #08251c 0%, #0e3d2f 14.57%, #1e7c60 37.06%, #08241c 80.5%, #051612 100%);
|
||||
--hm-sw-process-pill-background: linear-gradient(180deg, rgba(0, 0, 0, 0.851) 0, rgba(0, 0, 0, 0.851) 2px, rgba(0, 0, 0, 0.8) 4px);
|
||||
--hm-sw-process-pill-border-gradient: linear-gradient(180deg, #092920 0%, #1c6f56 115%);
|
||||
--hm-sw-process-tool-border-gradient: linear-gradient(180deg, #08251c 0%, #208769 46.54%, #08241c 84.44%, #051612 100%);
|
||||
--hm-sw-process-tool-icon-color: #b6d0c9;
|
||||
--hm-sw-process-close-hover-background: #e40000;
|
||||
--hm-sw-process-close-icon-hover: #fff;
|
||||
|
||||
/* Viewport split tool (Strana 화면 분할, StrAna-SW 6758:12510): open button face, its 3px extension, the 233 x 36
|
||||
selector (face, shadow; the border is the SW hover gradient) and the cells (light frame, green panes, yellow when
|
||||
hovered or checked), the button icon panes. */
|
||||
--hm-sw-process-tool-open-background: linear-gradient(180deg, #081813 0%, #0b3227 25.43%, #0e3d2f 51.25%, #0b3227 80.03%, #081813 100%);
|
||||
--hm-sw-process-tool-open-edge: #081813;
|
||||
--hm-sw-process-split-panel-background: #082018;
|
||||
--hm-sw-process-split-panel-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
|
||||
--hm-sw-process-split-cell-frame: var(--hm-sw-process-tool-icon-color);
|
||||
--hm-sw-process-split-cell-pane: #1b6b53;
|
||||
--hm-sw-process-split-icon-pane: #003425;
|
||||
|
||||
/* File name surface without a document (Figma 873:16481 "프로젝트 생성 전"): 10px 700 at the top right. */
|
||||
--hm-sw-process-document-empty-color: #aedbcc;
|
||||
|
||||
/* appearance="multi" step tab (Figma 873:16481 / 873:17421, 25px, as wide as its label + 25). Base = a group not
|
||||
reached yet (70%); lit = the lit group and the done groups; selected = the checked step; disabled = 50% on the
|
||||
SW property surface. Text #F0F7F5 / selected #FFC600 are the SW title / selected tokens. */
|
||||
--hm-sw-process-multi-tab-background: linear-gradient(180deg, rgba(52, 143, 114, 0.8) 0%, rgba(2, 50, 35, 0.8) 100%);
|
||||
--hm-sw-process-multi-tab-border: #0c3125;
|
||||
--hm-sw-process-multi-tab-lit-background: linear-gradient(180deg, rgba(68, 178, 143, 0.8) 0%, rgba(0, 61, 41, 0.8) 100%);
|
||||
--hm-sw-process-multi-tab-lit-border: #0a5940;
|
||||
--hm-sw-process-multi-tab-shadow: 1px 1px 1px 0 rgba(31, 148, 113, 0.7), 1px 1px 1px 0 rgba(31, 148, 113, 0.7);
|
||||
--hm-sw-process-multi-tab-selected-background: linear-gradient(0deg, #113228 0%, #28735c 100%);
|
||||
--hm-sw-process-multi-tab-selected-border: #001e15;
|
||||
--hm-sw-process-multi-tab-selected-shadow: 1px 1px 0.5px 0 rgba(31, 148, 113, 0.7), inset 3px 2px 3px 0 rgba(9, 39, 30, 0.42), inset 3px 2px 3px 0 rgba(9, 39, 30, 0.42);
|
||||
--hm-sw-process-multi-tab-disabled-border: #021912;
|
||||
--hm-sw-process-multi-tab-disabled-shadow: 1px 1px 1px 0 rgba(31, 148, 113, 0.7), inset 3px 2px 3px 0 rgba(9, 39, 30, 0.42), inset 3px 2px 3px 0 rgba(9, 39, 30, 0.42);
|
||||
--hm-sw-process-multi-tab-disabled-text: #7d9f91;
|
||||
}
|
||||
|
||||
/* ---------- Header frame ---------- */
|
||||
|
||||
.hm-sw-process-header {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: var(--hm-sw-header-height);
|
||||
background: var(--hm-sw-header-background);
|
||||
overflow: clip;
|
||||
}
|
||||
.hm-sw-process-header__bar { display: flex; align-items: stretch; height: var(--hm-sw-header-bar-height); min-width: 0; }
|
||||
|
||||
/* Logo and file name follow SWHeader (2026-11-03, both appearances): SWHeaderHome as it is (logo slot as wide as the
|
||||
logo, 32 x 17 example -> home 80, 12px padding, slant), the file name 2px after the home overlap (SWHeader bar gap)
|
||||
with the SW padding 5 / 12 and font. Only the file-name width stays per appearance. The text logo (`logoText`) is
|
||||
18px 700 in a 58px place; a wider word overflows that place evenly on both sides, like the Strana centered content. */
|
||||
.hm-sw-process-header__bar > .hm-sw-header__home { margin-right: -15px; }
|
||||
.hm-sw-process-header__wordmark {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
color: var(--hm-sw-header-text-color);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: var(--hm-sw-header-logo-height);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
text-shadow: var(--hm-sw-header-text-outline);
|
||||
}
|
||||
/* Logo menu open (host menu): the hover look stays, like Strana IsSubmenuOpen. */
|
||||
.hm-sw-process-header .hm-sw-header__home[aria-expanded="true"] .hm-sw-header__home-bg-hover { opacity: 1; }
|
||||
|
||||
/* File name: SWHeaderDocumentTitle; only its width follows the Strana column (286*, 100 .. 286). */
|
||||
.hm-sw-process-header__bar > .hm-sw-header__document { flex: 286 1 0; min-width: 100px; max-width: 286px; }
|
||||
/* No document list here, so no dropdown arrow (the Strana file name has none). */
|
||||
.hm-sw-process-header__bar > .hm-sw-header__document .hm-sw-header__arrow { display: none; }
|
||||
|
||||
.hm-sw-process-header__center { display: flex; flex: 1260 1 0; min-width: 0; }
|
||||
.hm-sw-process-header__center > .hm-sw-process-bar { flex: 1003 1 0; }
|
||||
|
||||
/* Layer surface (Header.Border.LayerFace): SW object surface, 1px apart on both sides, 150 .. 255. */
|
||||
.hm-sw-process-header__layer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 257 1 0;
|
||||
min-width: 152px;
|
||||
max-width: 257px;
|
||||
position: relative;
|
||||
height: var(--hm-sw-header-bar-height);
|
||||
}
|
||||
/* Surface on a pseudo element 1px in from both sides: no padding, border or margin on the flex item, so the
|
||||
1003 : 257 column ratio and its 152 .. 257 limits are the Strana ones exactly (surface 150 .. 255). */
|
||||
.hm-sw-process-header__layer::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 1px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--hm-sw-header-object-radius);
|
||||
background: var(--hm-sw-header-object-background) padding-box, var(--hm-sw-header-object-border-gradient) border-box;
|
||||
}
|
||||
/* Layer field (Header.ToggleButton.LayerField): 38px, at most 234, property surface + layer border gradient.
|
||||
The bulb and the field are sibling buttons; the field button covers the whole field under the bulb. */
|
||||
.hm-sw-process-header__layer-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 234px;
|
||||
height: var(--hm-sw-header-field-height);
|
||||
margin: 0 9px;
|
||||
padding: 0 10px 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--hm-sw-header-property-radius);
|
||||
background: var(--hm-sw-header-property-background) padding-box, var(--hm-sw-process-layer-border-gradient) border-box;
|
||||
box-shadow: var(--hm-sw-header-property-shadow);
|
||||
color: var(--hm-sw-header-text-color);
|
||||
}
|
||||
.hm-sw-process-header__layer-field::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
padding: var(--hm-sw-header-hover-border-width);
|
||||
border-radius: inherit;
|
||||
background: var(--hm-sw-header-hover-border-gradient);
|
||||
-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;
|
||||
}
|
||||
.hm-sw-process-header__layer-field:not([data-disabled]):hover::after,
|
||||
.hm-sw-process-header__layer-field[data-open]::after { opacity: 1; }
|
||||
.hm-sw-process-header__layer-field:not([data-disabled]):hover,
|
||||
.hm-sw-process-header__layer-field[data-open] { color: var(--hm-sw-header-selected-color); }
|
||||
.hm-sw-process-header__layer-field[data-disabled] { opacity: 0.4; }
|
||||
.hm-sw-process-header .hm-sw-process-header__layer-field button:disabled { opacity: 1; }
|
||||
.hm-sw-process-header__layer-button { position: absolute; inset: -1px; z-index: 0; border-radius: inherit; }
|
||||
.hm-sw-process-header__layer-field > .hm-sw-header__layer-toggle { z-index: 1; }
|
||||
.hm-sw-process-header__layer-text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 0 var(--hm-sw-header-text-outline-room);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hm-sw-process-header__layer-label,
|
||||
.hm-sw-process-header__layer-value { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.hm-sw-process-header__layer-label { margin-left: 1px; font-size: var(--hm-sw-header-label-size); line-height: 14px; text-shadow: var(--hm-sw-header-label-outline); }
|
||||
.hm-sw-process-header__layer-value { font-size: var(--hm-sw-header-font-size); line-height: 16px; text-shadow: var(--hm-sw-header-text-outline); }
|
||||
|
||||
/* Window area (Header.Border.Window): DocFace + DocBorder 1px behind; the buttons cover the full 48px on top. */
|
||||
.hm-sw-process-header__window { position: relative; display: flex; flex: 0 0 auto; height: var(--hm-sw-header-bar-height); }
|
||||
.hm-sw-process-header__window::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid transparent;
|
||||
background: var(--hm-sw-header-document-background) padding-box, var(--hm-sw-header-document-border-gradient) border-box;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hm-sw-process-header__window > * { position: relative; }
|
||||
|
||||
/* Tool pill: the SWToolGroup pill (40 x 38 buttons, 1px apart). `default` puts the Strana pill well and gradient
|
||||
borders on it; `multi` keeps the SW pill as it is (Figma 873:16481 uses the SW pill: solid #092920, rgba(0,0,0,.8),
|
||||
inner shadow, buttons with a solid #08251C border) at its own size. The selected (pressed) look — yellow ring and
|
||||
icon — is added for both. */
|
||||
.hm-sw-process-header .hm-sw-process-header__window .hm-sw-process-header__tools { flex: 0 0 auto; height: 42px; margin: 3px 0 0 3px; }
|
||||
.hm-sw-process-header:not([data-appearance="multi"]) .hm-sw-process-header__window .hm-sw-process-header__tools {
|
||||
border-color: transparent;
|
||||
background: var(--hm-sw-process-pill-background) padding-box, var(--hm-sw-process-pill-border-gradient) border-box;
|
||||
box-shadow: none;
|
||||
}
|
||||
.hm-sw-process-header:not([data-appearance="multi"]) .hm-sw-process-header__tools .hm-sw-tool__pill-button {
|
||||
border-color: transparent;
|
||||
background: var(--hm-sw-header-document-background) padding-box, var(--hm-sw-process-tool-border-gradient) border-box;
|
||||
}
|
||||
.hm-sw-process-header .hm-sw-process-header__tools .hm-sw-tool__pill-button { color: var(--hm-sw-process-tool-icon-color); }
|
||||
.hm-sw-process-header .hm-sw-process-header__tools .hm-sw-tool__pill-button:hover:not(:disabled),
|
||||
.hm-sw-process-header .hm-sw-process-header__tools .hm-sw-tool__pill-button[aria-pressed="true"] { color: var(--hm-sw-header-selected-color); }
|
||||
.hm-sw-process-header .hm-sw-process-header__tools .hm-sw-tool__pill-button[aria-pressed="true"]::after { opacity: 1; }
|
||||
/* Pressed (not disabled) shows the hover picture too, like a hover that stays (SW pill hover icon = `hoverIcon`). */
|
||||
.hm-sw-process-header .hm-sw-process-header__tools .hm-sw-tool__pill-button[aria-pressed="true"]:not(:disabled) > .hm-sw-header__icon { visibility: hidden; }
|
||||
.hm-sw-process-header .hm-sw-process-header__tools .hm-sw-tool__pill-button[aria-pressed="true"]:not(:disabled) > .hm-sw-header__icon-hover { visibility: visible; }
|
||||
.hm-sw-process-header .hm-sw-process-header__tools .hm-sw-tool__icon { inset: 0; }
|
||||
.hm-sw-process-header .hm-sw-process-header__tools .hm-sw-tool__icon > :is(svg, img) { width: 24px; height: 24px; max-width: none; max-height: none; }
|
||||
|
||||
/* Caption buttons (Header.WindowCaption, 32 x 48): glyph #ADB0AF; hover dark + white; close hover red + white. */
|
||||
.hm-sw-process-header__caption { display: block; flex: 0 0 32px; width: 32px; height: var(--hm-sw-header-bar-height); color: var(--hm-sw-header-window-icon-color); }
|
||||
.hm-sw-process-header__caption > svg { display: block; }
|
||||
.hm-sw-process-header__caption:hover:not(:disabled) { background: var(--hm-sw-header-window-button-hover-background); color: var(--hm-sw-header-window-icon-hover-color); }
|
||||
.hm-sw-process-header__caption[data-kind="close"]:hover:not(:disabled) { background: var(--hm-sw-process-close-hover-background); color: var(--hm-sw-process-close-icon-hover); }
|
||||
|
||||
/* ---------- Process area (SWProcessBar) ---------- */
|
||||
|
||||
.hm-sw-process-bar {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
height: var(--hm-sw-header-bar-height);
|
||||
/* Strana ClipToBounds: surfaces and the arrow shade never reach past the area (no scroll width when narrow). */
|
||||
overflow: clip;
|
||||
color: var(--hm-sw-process-tab-text);
|
||||
}
|
||||
.hm-sw-process-bar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: var(--hm-sw-process-base-background) padding-box, var(--hm-sw-process-base-border-gradient) border-box;
|
||||
}
|
||||
/* Rest of the width on both sides: same brightness as the outer group next to it. */
|
||||
.hm-sw-process-bar__rest { position: relative; flex: 1 1 0; min-width: 0; }
|
||||
.hm-sw-process-bar__rest[data-dim] { background: var(--hm-sw-process-dim); }
|
||||
/* The cover keeps the base's 1px side borders and round corners visible (Strana margin 1, radius 5). */
|
||||
.hm-sw-process-bar__rest[data-side="start"] { margin-left: 1px; border-top-left-radius: 5px; }
|
||||
.hm-sw-process-bar__rest[data-side="end"] { margin-right: 1px; border-top-right-radius: 5px; }
|
||||
|
||||
/* Group: [20][number 4 name][12][tabs][20]. Natural width at most; shrinks with the others in proportion. */
|
||||
.hm-sw-process-bar__group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.hm-sw-process-bar__group[data-dim]::before { content: ""; position: absolute; inset: 0; z-index: 0; background: var(--hm-sw-process-dim); }
|
||||
.hm-sw-process-bar__label,
|
||||
.hm-sw-process-bar__steps { position: relative; z-index: 3; }
|
||||
|
||||
.hm-sw-process-bar__label {
|
||||
display: flex;
|
||||
align-self: flex-start;
|
||||
flex: 0 0 auto;
|
||||
margin: 10.67px 12px 0 0;
|
||||
font-size: var(--hm-sw-header-label-size);
|
||||
line-height: 14px;
|
||||
text-shadow: var(--hm-sw-header-label-outline);
|
||||
}
|
||||
.hm-sw-process-bar__label[data-lines="2"] { margin-top: 8.67px; }
|
||||
.hm-sw-process-bar__number { flex: 0 0 auto; margin-right: 4px; color: var(--hm-sw-process-number-color); font-weight: 500; }
|
||||
.hm-sw-process-bar__name { display: grid; max-width: 56px; }
|
||||
.hm-sw-process-bar__name > * { grid-area: 1 / 1; }
|
||||
.hm-sw-process-bar__name-reserve { font-weight: 700; visibility: hidden; }
|
||||
.hm-sw-process-bar__name-text { justify-self: start; color: var(--hm-sw-process-group-color); font-weight: 500; }
|
||||
.hm-sw-process-bar__group[data-active] .hm-sw-process-bar__name-text { color: var(--hm-sw-process-group-active-color); font-weight: 700; }
|
||||
|
||||
/* Tabs (both appearances): a flex row where the checked tab shrinks with the others in the same proportion (2026-11-03,
|
||||
it no longer keeps its whole name); a name without room ends with an ellipsis. default: all tabs share the row
|
||||
equally (Strana UniformGrid, 127 each at most); multi overrides below. The group minimum (name + every tab at its
|
||||
padding) is measured by SWProcessBar, so tabs never overlap. */
|
||||
.hm-sw-process-bar__steps {
|
||||
display: flex;
|
||||
column-gap: 1px;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.hm-sw-process-bar__steps > .hm-sw-process-bar__step { flex: 1 1 0; }
|
||||
.hm-sw-process-bar__step {
|
||||
--hm-sw-process-step-radius: 4px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 12px 2.67px;
|
||||
border-radius: var(--hm-sw-process-step-radius);
|
||||
color: var(--hm-sw-process-tab-text);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
text-shadow: var(--hm-sw-header-text-outline);
|
||||
}
|
||||
.hm-sw-process-bar__step[data-shape="single"] { --hm-sw-process-step-radius: 16px; }
|
||||
.hm-sw-process-bar__step[data-shape="start"] { --hm-sw-process-step-radius: 16px 4px 4px 16px; }
|
||||
.hm-sw-process-bar__step[data-shape="end"] { --hm-sw-process-step-radius: 4px 16px 16px 4px; }
|
||||
.hm-sw-process-bar__step-text,
|
||||
.hm-sw-process-bar__step-size { grid-area: 1 / 1; }
|
||||
/* The outline may paint into the room outside the clipped (ellipsis) text, like the SW file name. */
|
||||
.hm-sw-process-bar__step-text { position: relative; max-width: 100%; overflow: clip; overflow-clip-margin: var(--hm-sw-header-text-outline-room); white-space: nowrap; text-overflow: ellipsis; }
|
||||
/* Natural width 127 (Strana MinWidth) = 103 + 24 padding; never wider than the column. */
|
||||
.hm-sw-process-bar__step-size { display: block; width: 103px; max-width: 100%; height: 0; }
|
||||
|
||||
.hm-sw-process-bar__step-box { position: absolute; inset: 0; border-radius: inherit; opacity: 0.7; pointer-events: none; }
|
||||
.hm-sw-process-bar__step-box > i { position: absolute; inset: 0; border-radius: inherit; }
|
||||
.hm-sw-process-bar__step-box > [data-layer^="glow"] { inset: 1px -1px -1px 1px; border-right: 1px solid var(--hm-sw-process-tab-glow); border-bottom: 1px solid var(--hm-sw-process-tab-glow); }
|
||||
.hm-sw-process-bar__step-box > [data-layer="depth"] { background: var(--hm-sw-process-tab-depth); }
|
||||
.hm-sw-process-bar__step-box > [data-layer="shade"] { background: var(--hm-sw-process-tab-shade); }
|
||||
.hm-sw-process-bar__step-box > [data-layer="sheen"] { background: var(--hm-sw-process-tab-sheen); }
|
||||
.hm-sw-process-bar__step-box > [data-layer="frame"] {
|
||||
--hm-sw-process-frame: 1px;
|
||||
padding: var(--hm-sw-process-frame);
|
||||
background: var(--hm-sw-process-tab-border-gradient);
|
||||
-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.85;
|
||||
}
|
||||
.hm-sw-process-bar__step-box > [data-layer^="inset"] { display: none; }
|
||||
.hm-sw-process-bar__step-box > [data-layer="inset-top"] { background: linear-gradient(180deg, var(--hm-sw-process-tab-inset) 0, transparent 5px); }
|
||||
.hm-sw-process-bar__step-box > [data-layer="inset-left"] { background: linear-gradient(90deg, var(--hm-sw-process-tab-inset) 0, transparent 6px); }
|
||||
.hm-sw-process-bar__step-box > [data-layer="ring"] { border: 2px solid var(--hm-sw-process-tab-ring); }
|
||||
|
||||
.hm-sw-process-bar__step:hover:not(:disabled) { color: var(--hm-sw-header-selected-color); }
|
||||
.hm-sw-process-bar__step[aria-checked="true"] { color: var(--hm-sw-header-selected-color); }
|
||||
.hm-sw-process-bar__step[aria-checked="true"] > .hm-sw-process-bar__step-box { opacity: 1; }
|
||||
.hm-sw-process-bar__step[aria-checked="true"] [data-layer="depth"] { background: var(--hm-sw-process-tab-active-base); }
|
||||
.hm-sw-process-bar__step[aria-checked="true"] [data-layer="shade"] { background: var(--hm-sw-process-tab-active-shade); }
|
||||
.hm-sw-process-bar__step[aria-checked="true"] [data-layer="sheen"] { background: var(--hm-sw-process-tab-active-sheen); }
|
||||
.hm-sw-process-bar__step[aria-checked="true"] [data-layer^="inset"] { display: block; opacity: 0.75; }
|
||||
.hm-sw-process-bar__step[aria-checked="true"] [data-layer="frame"] { --hm-sw-process-frame: 2px; opacity: 0.25; }
|
||||
.hm-sw-process-bar__step[aria-checked="true"] [data-layer="glow2"] { display: none; }
|
||||
/* Disabled (after Checked, like the Strana trigger order). The SW button reset's 40% is replaced by the tab look. */
|
||||
.hm-sw-process-bar .hm-sw-process-bar__step:disabled { opacity: 1; color: var(--hm-sw-process-tab-disabled-text); cursor: default; }
|
||||
.hm-sw-process-bar__step:disabled > .hm-sw-process-bar__step-box { opacity: 0.5; }
|
||||
.hm-sw-process-bar__step:disabled [data-layer="depth"] { background: var(--hm-sw-process-tab-active-base); }
|
||||
.hm-sw-process-bar__step:disabled [data-layer="shade"] { background: var(--hm-sw-process-tab-disabled-shade); }
|
||||
.hm-sw-process-bar__step:disabled [data-layer="sheen"] { background: var(--hm-sw-process-tab-disabled-sheen); }
|
||||
.hm-sw-process-bar__step:disabled [data-layer^="inset"] { display: block; }
|
||||
.hm-sw-process-bar__step:disabled [data-layer="glow2"] { display: none; }
|
||||
|
||||
/* Group surfaces: 40px out on the left of the first group, mirrored and 44px out on the right of the last one.
|
||||
Clipped to their box like the Strana ClipToBounds. */
|
||||
.hm-sw-process-bar__shape { position: absolute; top: 0; left: calc(-1 * var(--hm-sw-process-shape-start, 40px)); right: 0; z-index: 1; height: var(--hm-sw-header-bar-height); overflow: hidden; pointer-events: none; }
|
||||
.hm-sw-process-bar__shape[data-mirrored] { left: 0; right: calc(-1 * var(--hm-sw-process-shape-end, 44px)); transform: scaleX(-1); }
|
||||
.hm-sw-process-bar__shape > * { position: absolute; }
|
||||
|
||||
/* Lit: halo around the face (filter on the parent so the clipped face still glows), face fading at the arrow end. */
|
||||
.hm-sw-process-bar__glow-halo { inset: 0; filter: drop-shadow(0 0 30px var(--hm-sw-process-glow-halo)); }
|
||||
.hm-sw-process-bar__glow-face {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -4.9526px;
|
||||
height: 49.1162px;
|
||||
clip-path: path("M38.99,8.97 C38.41,4.2025 42.13,0 46.94,0 H99999 V49.1162 H50.98 C46.94,49.1162 43.53,46.0995 43.04,42.0862 Z");
|
||||
-webkit-mask: linear-gradient(90deg, #000 calc(100% - 83.4px), transparent calc(100% - 7.4px));
|
||||
mask: linear-gradient(90deg, #000 calc(100% - 83.4px), transparent calc(100% - 7.4px));
|
||||
}
|
||||
.hm-sw-process-bar__glow-face > i { position: absolute; inset: 0; }
|
||||
.hm-sw-process-bar__glow-face > i:nth-child(1) { background: var(--hm-sw-process-glow-face-base); opacity: 0.85; }
|
||||
.hm-sw-process-bar__glow-face > i:nth-child(2) { background: var(--hm-sw-process-glow-face-rim); opacity: 0.8; }
|
||||
.hm-sw-process-bar__glow-face > i:nth-child(3) { background: var(--hm-sw-process-glow-face-sheen); opacity: 0.45; }
|
||||
.hm-sw-process-bar__glow-edge {
|
||||
left: 0;
|
||||
top: 0;
|
||||
fill: none;
|
||||
stroke: var(--hm-sw-process-glow-edge);
|
||||
stroke-width: 3;
|
||||
filter: blur(5px);
|
||||
-webkit-mask: linear-gradient(180deg, #000 64%, rgba(0, 0, 0, 0.302) 91%);
|
||||
mask: linear-gradient(180deg, #000 64%, rgba(0, 0, 0, 0.302) 91%);
|
||||
}
|
||||
|
||||
/* Unlit: blurred face with a dark halo and a rim light, fading from the outer end. */
|
||||
.hm-sw-process-bar__shape[data-kind="idle"] {
|
||||
-webkit-mask: linear-gradient(90deg, #000 79.8px, transparent 198.4px);
|
||||
mask: linear-gradient(90deg, #000 79.8px, transparent 198.4px);
|
||||
}
|
||||
.hm-sw-process-bar__shape[data-kind="idle"][data-mirrored] {
|
||||
-webkit-mask: linear-gradient(90deg, #000 236.7px, transparent 273.1px);
|
||||
mask: linear-gradient(90deg, #000 236.7px, transparent 273.1px);
|
||||
}
|
||||
.hm-sw-process-bar__idle-halo { inset: 0; filter: drop-shadow(0 0 15px var(--hm-sw-process-idle-halo)); }
|
||||
.hm-sw-process-bar__idle-blur { position: absolute; inset: 0; filter: blur(4px); }
|
||||
.hm-sw-process-bar__idle-face {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -4.95264px;
|
||||
height: 49.2497px;
|
||||
background: var(--hm-sw-process-idle-face);
|
||||
clip-path: path("M99999,0 H47.153 C42.095,0 38.306,4.63511 39.313,9.59189 L46.064,42.84164 C46.821,46.57024 50.099,49.24974 53.904,49.24974 H99999 Z");
|
||||
}
|
||||
.hm-sw-process-bar__idle-rim { left: 0; top: 0; fill: none; stroke: var(--hm-sw-process-idle-rim); stroke-width: 1.5; filter: blur(1px); }
|
||||
.hm-sw-process-bar__idle-rim > rect { fill: var(--hm-sw-process-idle-rim); stroke: none; }
|
||||
|
||||
/* Arrow cell (16px): dim cover under the surfaces, the arrow above them, its shade over the next group. */
|
||||
.hm-sw-process-bar__arrow { position: relative; flex: 0 0 16px; width: 16px; }
|
||||
.hm-sw-process-bar__arrow > svg { position: absolute; left: 0; top: 0; display: block; }
|
||||
.hm-sw-process-bar__arrow-dim { z-index: 0; height: 100%; fill: var(--hm-sw-process-dim); }
|
||||
.hm-sw-process-bar__arrow-art { z-index: 2; pointer-events: none; }
|
||||
.hm-sw-process-bar__arrow-shadow { stroke: var(--hm-sw-process-arrow-shadow); }
|
||||
|
||||
/* ---------- Shared additions (both appearances) ---------- */
|
||||
|
||||
/* A group name kept for assistive technology only (`labelHidden`). */
|
||||
.hm-sw-process-bar__label[data-label-hidden] { display: none; }
|
||||
|
||||
/* No document (`documentEmptyLabel`, Figma 873:16481): a small label at the top right of the file-name surface. */
|
||||
.hm-sw-process-header[data-document-empty] .hm-sw-header__document-button { align-items: flex-start; }
|
||||
.hm-sw-process-header[data-document-empty] .hm-sw-header__document-name {
|
||||
margin-top: 7px;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
letter-spacing: -0.1px;
|
||||
text-align: right;
|
||||
text-shadow: var(--hm-sw-header-label-outline);
|
||||
}
|
||||
.hm-sw-process-header[data-document-empty] .hm-sw-header__document-button:not(:hover) .hm-sw-header__document-name { color: var(--hm-sw-process-document-empty-color); }
|
||||
|
||||
/* ---------- appearance="multi" (StrAna-SW multi step, Figma 873:16481 / 873:16254 / 873:17421) ----------
|
||||
Same markup and shared rules as the default; only sizes, the file name, the tool pill and the group content differ.
|
||||
Text outline, hover, focus and the window-move motion are the shared SW rules (Figma texts have no stroke; the SW
|
||||
outline is applied on purpose, 2026-11-03 user request). */
|
||||
|
||||
/* Frame: 49px surfaces (no 1px gap). Logo and file-name text as the default (SWHeader). */
|
||||
.hm-sw-theme.hm-sw-process-header[data-appearance="multi"] { --hm-sw-header-bar-height: 49px; }
|
||||
/* SWHeader window move at the Figma place of this frame: stripes 82 x 39 at (3, 5), the icon (and the motion with its
|
||||
tooltip) 3px higher, at (28, 10). Only positions; the motion itself is the SWHeader one. */
|
||||
.hm-sw-process-header[data-appearance="multi"] .hm-sw-header__motion { background-position: 3px 5px; background-size: 82px 39px; }
|
||||
.hm-sw-process-header[data-appearance="multi"] .hm-sw-header__motion > * { translate: 0 -3px; }
|
||||
/* File name: 189 at most (Figma 873:16254), solid #0E4C3A border. */
|
||||
.hm-sw-process-header[data-appearance="multi"] .hm-sw-process-header__bar > .hm-sw-header__document { flex: 189 1 0; max-width: 189px; }
|
||||
.hm-sw-process-header[data-appearance="multi"] .hm-sw-process-header__bar > .hm-sw-header__document .hm-sw-header__document-button {
|
||||
background: var(--hm-sw-header-document-background) padding-box, linear-gradient(var(--hm-sw-header-document-border-color) 0 0) border-box;
|
||||
}
|
||||
.hm-sw-process-header[data-appearance="multi"] .hm-sw-process-header__center { flex: 1278 1 0; }
|
||||
.hm-sw-process-header[data-appearance="multi"] .hm-sw-process-header__window::before {
|
||||
background: var(--hm-sw-header-document-background) padding-box, linear-gradient(var(--hm-sw-header-document-border-color) 0 0) border-box;
|
||||
}
|
||||
/* SW pill at the Figma place: 41 high, 4 from the top, 5 from the window start, 2 before the window move. */
|
||||
.hm-sw-process-header[data-appearance="multi"] .hm-sw-process-header__window .hm-sw-process-header__tools { height: 41px; margin: 4px 2px 0 5px; }
|
||||
.hm-sw-process-header[data-appearance="multi"] .hm-sw-process-header__tools .hm-sw-tool__pill-button { height: 37px; }
|
||||
|
||||
/* Process area: 49px, groups [10][title 14 / tabs 25][10] with the arrow between (content to content 36). */
|
||||
.hm-sw-process-bar[data-appearance="multi"] {
|
||||
--hm-sw-header-bar-height: 49px;
|
||||
--hm-sw-process-shape-start: 50px;
|
||||
--hm-sw-process-shape-end: 48px;
|
||||
}
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 3.8px 10px 0;
|
||||
}
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__label {
|
||||
align-self: stretch;
|
||||
height: 14px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__label[data-label-hidden] { display: flex; visibility: hidden; }
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__number { margin-right: 2px; }
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__name { grid-template-columns: minmax(0, 1fr); flex: 0 1 auto; min-width: 0; max-width: none; }
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__name > * {
|
||||
overflow: clip;
|
||||
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__group[data-progress="done"] .hm-sw-process-bar__name-text {
|
||||
color: var(--hm-sw-process-group-active-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Tabs as wide as their label (padding 13 / 12), no gap; ends round (50). All of them, the checked one included,
|
||||
shrink in proportion to that width with an ellipsis. */
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__steps {
|
||||
align-items: stretch;
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
margin-top: 1.9px;
|
||||
}
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__steps > .hm-sw-process-bar__step { flex: 0 1 auto; }
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__step {
|
||||
--hm-sw-process-step-radius: 0;
|
||||
height: 25px;
|
||||
padding: 0 12px 0 13px;
|
||||
font-size: 14px;
|
||||
line-height: 23px;
|
||||
}
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__step[data-shape="single"] { --hm-sw-process-step-radius: 50px; }
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__step[data-shape="start"] { --hm-sw-process-step-radius: 50px 0 0 50px; }
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__step[data-shape="end"] { --hm-sw-process-step-radius: 0 50px 50px 0; }
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__step-size { display: none; }
|
||||
|
||||
/* One face instead of the default stacked layers. */
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__step-box {
|
||||
border: 1px solid var(--hm-sw-process-multi-tab-border);
|
||||
background: var(--hm-sw-process-multi-tab-background);
|
||||
box-shadow: var(--hm-sw-process-multi-tab-shadow);
|
||||
}
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__step-box > i { display: none; }
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__group:is([data-active], [data-progress="done"]) .hm-sw-process-bar__step-box {
|
||||
border-color: var(--hm-sw-process-multi-tab-lit-border);
|
||||
background: var(--hm-sw-process-multi-tab-lit-background);
|
||||
opacity: 1;
|
||||
}
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__group .hm-sw-process-bar__step[aria-checked="true"] > .hm-sw-process-bar__step-box {
|
||||
border-color: var(--hm-sw-process-multi-tab-selected-border);
|
||||
background: var(--hm-sw-process-multi-tab-selected-background);
|
||||
box-shadow: var(--hm-sw-process-multi-tab-selected-shadow);
|
||||
opacity: 1;
|
||||
}
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__step:disabled { color: var(--hm-sw-process-multi-tab-disabled-text); }
|
||||
.hm-sw-process-bar[data-appearance="multi"] .hm-sw-process-bar__group .hm-sw-process-bar__step:disabled > .hm-sw-process-bar__step-box {
|
||||
border-color: var(--hm-sw-process-multi-tab-disabled-border);
|
||||
background: var(--hm-sw-header-property-background);
|
||||
box-shadow: var(--hm-sw-process-multi-tab-disabled-shadow);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ---------- Viewport split tool (SWProcessSplitButton, both appearances) ---------- */
|
||||
|
||||
.hm-sw-process-split { position: relative; display: flex; }
|
||||
.hm-sw-process-split__icon-pane { fill: var(--hm-sw-process-split-icon-pane); stroke: #000; stroke-width: 0.962; }
|
||||
.hm-sw-process-split__icon > rect:first-child { stroke: #000; stroke-width: 0.962; }
|
||||
|
||||
/* Open (Header.ToggleButton.ToolMenu checked): darker face, gold top and sides, open at the bottom, no hover ring,
|
||||
yellow icon. The bottom border is replaced by padding so the icon never moves. */
|
||||
.hm-sw-process-header .hm-sw-process-header__tools .hm-sw-process-split[data-open] > .hm-sw-tool__pill-button {
|
||||
padding-bottom: 1px;
|
||||
border-color: transparent;
|
||||
border-bottom-width: 0;
|
||||
border-radius: 2px 2px 0 0;
|
||||
background: var(--hm-sw-process-tool-open-background) padding-box, var(--hm-sw-header-hover-border-gradient) border-box;
|
||||
color: var(--hm-sw-header-selected-color);
|
||||
}
|
||||
.hm-sw-process-header .hm-sw-process-header__tools .hm-sw-process-split[data-open] > .hm-sw-tool__pill-button::after { opacity: 0; }
|
||||
|
||||
/* Selector: fixed overlay 1px over the button bottom (no gap at any zoom), aligned to the button left (shifted only at
|
||||
the screen edge — --hm-sw-process-split-offset keeps the extension under the button). The extension reaches the
|
||||
header bottom (--hm-sw-process-split-tail, Strana 3px), so the panel never covers the window buttons. */
|
||||
.hm-sw-process-split__popup { position: fixed; z-index: 30; width: 233px; height: calc(var(--hm-sw-process-split-tail, 3px) + 36px); }
|
||||
.hm-sw-process-split__tail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--hm-sw-process-split-offset, 0px);
|
||||
width: 40px;
|
||||
height: var(--hm-sw-process-split-tail, 3px);
|
||||
border: solid transparent;
|
||||
border-width: 0 1px;
|
||||
background: linear-gradient(var(--hm-sw-process-tool-open-edge), var(--hm-sw-process-tool-open-edge)) padding-box, var(--hm-sw-header-hover-border-gradient) border-box;
|
||||
}
|
||||
.hm-sw-process-split__panel {
|
||||
position: absolute;
|
||||
top: var(--hm-sw-process-split-tail, 3px);
|
||||
left: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
width: 233px;
|
||||
height: 36px;
|
||||
padding: 0.5px 0;
|
||||
border: 1px solid transparent;
|
||||
border-top-width: 0;
|
||||
border-radius: 0 2px 2px 2px;
|
||||
background: linear-gradient(var(--hm-sw-process-split-panel-background), var(--hm-sw-process-split-panel-background)) padding-box, var(--hm-sw-header-hover-border-gradient) border-box;
|
||||
box-shadow: var(--hm-sw-process-split-panel-shadow);
|
||||
}
|
||||
/* Gold line on the panel top, from the button right edge to the panel end (Header.Border.ToolMenuStep). */
|
||||
.hm-sw-process-split__step {
|
||||
position: absolute;
|
||||
top: var(--hm-sw-process-split-tail, 3px);
|
||||
left: calc(var(--hm-sw-process-split-offset, 0px) + 39px);
|
||||
right: 0;
|
||||
height: 1px;
|
||||
border-top-right-radius: 2px;
|
||||
background: var(--hm-sw-header-hover-border-gradient) 0 0 / 100% 36px no-repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hm-sw-process-split__cell { display: grid; place-items: center; height: 34px; }
|
||||
/* Whole-pixel frame around the 29.6 x 19.73 picture (Strana 31.6 x 21.73): a fractional box is snapped to 21 or 22 rows
|
||||
depending on where the popup sits, and at 21 the light edge under the panes vanished (icons looked cut at 100%). */
|
||||
.hm-sw-process-split__frame {
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 22px;
|
||||
border: 1px solid #000;
|
||||
border-radius: 2px;
|
||||
background: var(--hm-sw-process-split-cell-frame);
|
||||
}
|
||||
.hm-sw-process-split__frame > svg { display: block; overflow: visible; }
|
||||
.hm-sw-process-split__pane { fill: var(--hm-sw-process-split-cell-pane); stroke: rgba(0, 0, 0, 0.3); stroke-width: 1; }
|
||||
.hm-sw-process-split__cell:is(:hover, [aria-checked="true"]) .hm-sw-process-split__pane { fill: var(--hm-sw-header-selected-color); }
|
||||
Reference in new issue
Block a user