fix(sw): preserve theme token inheritance across viewer components
This commit is contained in:
1 parent
40c5b89814
commit
41e963a12a
17 files changed
+445
-21
No files matched your search
+1
-1
@@ -133,7 +133,7 @@
|
||||
| 패키지 | 스타일이 적용되는 범위 | 주의 |
|
||||
|---|---|---|
|
||||
| Web | `theme.css`의 토큰은 `:root, [data-hm-theme]`. `tailwind.css`는 Tailwind base에서 `*`(border·outline), `body`(배경·글자), `html`(글꼴)을 **문서 전체에** 적용. 앱이 import하는 `tailwindcss`의 Preflight도 전역 | Web CSS를 넣은 문서에서는 다른 영역에도 전역 초기화가 적용됨 |
|
||||
| SW(일반·뷰어 공통) | 토큰은 `.hm-sw-header`와 `.hm-sw-theme`에만 정의(뷰어 토큰 `--hm-sw-viewer-*`도 같은 범위). 전역 선택자 없음. `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`은 자기 root에 `hm-sw-theme`를 가짐 | 일반 모드의 Header 밖 SW 부품은 반드시 `<SWTheme>` 안 |
|
||||
| SW(일반·뷰어 공통) | 토큰은 `.hm-sw-header`와 `.hm-sw-theme`에만 정의(뷰어 토큰 `--hm-sw-viewer-*`도 같은 범위). 전역 선택자 없음. `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`은 자기 root에 `hm-sw-theme`를 가짐. 토큰 기본값은 가장 바깥 SW 범위에서만 선언되고 안쪽 SW 범위는 상속(변경은 가장 바깥 범위의 `style`, SW `AI_USAGE.md` §3.1) | 일반 모드의 Header 밖 SW 부품은 반드시 `<SWTheme>` 안 |
|
||||
| OrgChart | Core가 자기 open Shadow DOM에만 CSS 로드. 호스트 CSS selector는 안쪽에 닿지 않고 **Custom Property 상속만** 통과 | 호스트 class로 카드 모양을 바꾸려 하지 않음 |
|
||||
|
||||
혼합 규칙:
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| `docs/ai/AGENTS_SNIPPET.md` | 소비 프로젝트 루트 `AGENTS.md`에 붙일 연결 문구 원본 | 사본으로 포함 |
|
||||
| `docs/ai/MAINTAINING.md` | 이 문서 | 미포함 |
|
||||
| `packages/ui/ai/` | Web: `AI_USAGE`, `COMPONENT_CATALOG`, `DESIGN_RULES`, `COMPOSITION_PATTERNS` | 포함(`files`) |
|
||||
| `packages/sw/ai/` | SW: 위 4종(`AI_USAGE.md` §0 = SW 일반/뷰어 모드 선택) + 뷰어 모드 전용 `VIEWER.md` + `examples/`(A~F 일반 모드, H 뷰어 모드) | 포함 |
|
||||
| `packages/sw/ai/` | SW: 위 4종(`AI_USAGE.md` §0 = SW 일반/뷰어 모드 선택) + 뷰어 모드 전용 `VIEWER.md` + `examples/`(A~F 일반 모드, H 뷰어 모드, J 테마 설정) | 포함 |
|
||||
| `packages/org-chart/ai/` | OrgChart(Core + React 어댑터 API): 위 4종 | 포함 |
|
||||
| `packages/org-chart-react/ai/` | `AI_USAGE`(Core `ai/` 안내) | 포함 |
|
||||
| `scripts/ai-docs.mjs` | 동기화(`--sync`)·검사(`--check`) | — |
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
| 패키지 | 스타일이 적용되는 범위 | 주의 |
|
||||
|---|---|---|
|
||||
| Web | `theme.css`의 토큰은 `:root, [data-hm-theme]`. `tailwind.css`는 Tailwind base에서 `*`(border·outline), `body`(배경·글자), `html`(글꼴)을 **문서 전체에** 적용. 앱이 import하는 `tailwindcss`의 Preflight도 전역 | Web CSS를 넣은 문서에서는 다른 영역에도 전역 초기화가 적용됨 |
|
||||
| SW(일반·뷰어 공통) | 토큰은 `.hm-sw-header`와 `.hm-sw-theme`에만 정의(뷰어 토큰 `--hm-sw-viewer-*`도 같은 범위). 전역 선택자 없음. `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`은 자기 root에 `hm-sw-theme`를 가짐 | 일반 모드의 Header 밖 SW 부품은 반드시 `<SWTheme>` 안 |
|
||||
| SW(일반·뷰어 공통) | 토큰은 `.hm-sw-header`와 `.hm-sw-theme`에만 정의(뷰어 토큰 `--hm-sw-viewer-*`도 같은 범위). 전역 선택자 없음. `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`은 자기 root에 `hm-sw-theme`를 가짐. 토큰 기본값은 가장 바깥 SW 범위에서만 선언되고 안쪽 SW 범위는 상속(변경은 가장 바깥 범위의 `style`, SW `AI_USAGE.md` §3.1) | 일반 모드의 Header 밖 SW 부품은 반드시 `<SWTheme>` 안 |
|
||||
| OrgChart | Core가 자기 open Shadow DOM에만 CSS 로드. 호스트 CSS selector는 안쪽에 닿지 않고 **Custom Property 상속만** 통과 | 호스트 class로 카드 모양을 바꾸려 하지 않음 |
|
||||
|
||||
혼합 규칙:
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
| 패키지 | 스타일이 적용되는 범위 | 주의 |
|
||||
|---|---|---|
|
||||
| Web | `theme.css`의 토큰은 `:root, [data-hm-theme]`. `tailwind.css`는 Tailwind base에서 `*`(border·outline), `body`(배경·글자), `html`(글꼴)을 **문서 전체에** 적용. 앱이 import하는 `tailwindcss`의 Preflight도 전역 | Web CSS를 넣은 문서에서는 다른 영역에도 전역 초기화가 적용됨 |
|
||||
| SW(일반·뷰어 공통) | 토큰은 `.hm-sw-header`와 `.hm-sw-theme`에만 정의(뷰어 토큰 `--hm-sw-viewer-*`도 같은 범위). 전역 선택자 없음. `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`은 자기 root에 `hm-sw-theme`를 가짐 | 일반 모드의 Header 밖 SW 부품은 반드시 `<SWTheme>` 안 |
|
||||
| SW(일반·뷰어 공통) | 토큰은 `.hm-sw-header`와 `.hm-sw-theme`에만 정의(뷰어 토큰 `--hm-sw-viewer-*`도 같은 범위). 전역 선택자 없음. `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`은 자기 root에 `hm-sw-theme`를 가짐. 토큰 기본값은 가장 바깥 SW 범위에서만 선언되고 안쪽 SW 범위는 상속(변경은 가장 바깥 범위의 `style`, SW `AI_USAGE.md` §3.1) | 일반 모드의 Header 밖 SW 부품은 반드시 `<SWTheme>` 안 |
|
||||
| OrgChart | Core가 자기 open Shadow DOM에만 CSS 로드. 호스트 CSS selector는 안쪽에 닿지 않고 **Custom Property 상속만** 통과 | 호스트 class로 카드 모양을 바꾸려 하지 않음 |
|
||||
|
||||
혼합 규칙:
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
| 패키지 | 스타일이 적용되는 범위 | 주의 |
|
||||
|---|---|---|
|
||||
| Web | `theme.css`의 토큰은 `:root, [data-hm-theme]`. `tailwind.css`는 Tailwind base에서 `*`(border·outline), `body`(배경·글자), `html`(글꼴)을 **문서 전체에** 적용. 앱이 import하는 `tailwindcss`의 Preflight도 전역 | Web CSS를 넣은 문서에서는 다른 영역에도 전역 초기화가 적용됨 |
|
||||
| SW(일반·뷰어 공통) | 토큰은 `.hm-sw-header`와 `.hm-sw-theme`에만 정의(뷰어 토큰 `--hm-sw-viewer-*`도 같은 범위). 전역 선택자 없음. `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`은 자기 root에 `hm-sw-theme`를 가짐 | 일반 모드의 Header 밖 SW 부품은 반드시 `<SWTheme>` 안 |
|
||||
| SW(일반·뷰어 공통) | 토큰은 `.hm-sw-header`와 `.hm-sw-theme`에만 정의(뷰어 토큰 `--hm-sw-viewer-*`도 같은 범위). 전역 선택자 없음. `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`은 자기 root에 `hm-sw-theme`를 가짐. 토큰 기본값은 가장 바깥 SW 범위에서만 선언되고 안쪽 SW 범위는 상속(변경은 가장 바깥 범위의 `style`, SW `AI_USAGE.md` §3.1) | 일반 모드의 Header 밖 SW 부품은 반드시 `<SWTheme>` 안 |
|
||||
| OrgChart | Core가 자기 open Shadow DOM에만 CSS 로드. 호스트 CSS selector는 안쪽에 닿지 않고 **Custom Property 상속만** 통과 | 호스트 class로 카드 모양을 바꾸려 하지 않음 |
|
||||
|
||||
혼합 규칙:
|
||||
|
||||
@@ -103,6 +103,7 @@ import "@hm-design/sw/styles.css" // 앱 진입점에서 한 번
|
||||
- Peer: `react`, `react-dom` `^19.0.0`. 런타임 dependency 없음. `@hm-design/ui`·조직도 패키지와 무관하다.
|
||||
- SVG·PNG 자산은 JS에 data URI로 포함되어 별도 설정이 없다.
|
||||
- 글꼴 파일은 포함하지 않는다. 앱에서 Noto Sans KR(400/500/700)을 로드한다(없으면 Malgun Gothic → Apple SD Gothic Neo).
|
||||
- SW 기본 글꼴은 Noto Sans KR이다(Figma는 Dotum). **Dotum을 기본으로 쓸지는 디자인 승인 대기**이므로 AI가 임의로 Dotum을 적용하지 않는다. 프로그램(사용자)이 글꼴을 정한 경우에만 §3.1 방법으로 `--hm-sw-header-font-family`를 바꾸고, 결과 보고에 "글꼴 변경 — 디자인 승인 대기 항목"으로 적는다.
|
||||
- CSS는 전역(`html`, `body`, `*`)을 건드리지 않는다. `body` margin 등 페이지 기본값은 소비 앱이 정한다.
|
||||
- 스크롤바: SW 전용 스크롤바 디자인은 없다. 기존 SW 컴포넌트는 브라우저 기본 스크롤바를 쓰며(속성Bar만 `scrollbar-width: thin`), Popup 본문 스크롤은 `SWPopup`이 처리한다. 새 스크롤 영역에 임의 스크롤바 색·두께를 만들지 않는다.
|
||||
- TypeScript에서 CSS side-effect import 오류가 나면 Vite의 `vite/client` 타입을 포함한다.
|
||||
@@ -112,9 +113,27 @@ import "@hm-design/sw/styles.css" // 앱 진입점에서 한 번
|
||||
- `SWHeader` 밖에서 하위 컴포넌트를 쓰면 반드시 `<SWTheme>`로 감싼다. 토큰·글꼴·버튼 초기화가 이 범위에서만 적용된다.
|
||||
- 소비 코드는 `hm-sw-*` class를 붙이거나 재정의하지 않는다. 배치는 자기 class(`className`)와 `style`로 한다.
|
||||
- 뷰어 모드 컴포넌트(`SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`)는 자체 `hm-sw-theme` 범위를 가지므로 `<SWTheme>`로 감싸지 않아도 된다. 소비 코드는 `hm-sw-viewer-*` class도 붙이거나 재정의하지 않는다.
|
||||
- 외형 변경은 `--hm-sw-header-*`(뷰어 모드는 `--hm-sw-viewer-*`) 토큰을 `SWTheme`/`SWHeader`(뷰어 모드는 해당 컴포넌트)의 `style`로 바꾸는 방법만 쓴다. 사용자 확정 면(파일명·객체 수정·속성 입력)은 기본값 유지를 권장한다.
|
||||
- 외형 변경은 §3.1의 방법만 쓴다. 사용자 확정 면(파일명·객체 수정·속성 입력)은 기본값 유지를 권장한다.
|
||||
- Light/Dark 분기는 없다. SW 디자인은 하나다.
|
||||
|
||||
### 3.1 테마(토큰) 설정 — 공식 방법 (2026-10-20)
|
||||
|
||||
SW 토큰(`--hm-sw-header-*`, `--hm-sw-property-*`, `--hm-sw-viewer-*`, `--hm-sw-footer-*`)의 기본값은 **가장 바깥 SW 범위**(`<SWTheme>`, 또는 단독으로 쓴 `SWHeader`·`SWViewerHeader`·`SWViewerPanel`·`SWViewerMenu`·`SWFooter`의 루트)에서 한 번만 선언됩니다. 그 안의 SW 요소 — Header의 Dropdown·More, `SWViewerMenu`, `SWViewerPanel`과 안쪽 컨트롤, `SWFooterCommandLine`, Tooltip·안내 — 는 자기 루트에 `hm-sw-theme`이 있어도 값을 다시 정하지 않고 **상속**합니다.
|
||||
|
||||
1. 화면 전체(Header·Panel·Menu가 같은 값)를 바꾸려면 화면을 `<SWTheme style={theme}>` 하나로 감싸고 거기서만 토큰을 정합니다. 프로그램 class(`<SWTheme className="app-sw-theme">` + `.app-sw-theme { --hm-sw-header-font-family: … }`)도 됩니다.
|
||||
```tsx
|
||||
const appSWTheme = { "--hm-sw-header-font-family": '"Noto Sans KR", sans-serif' } as CSSProperties
|
||||
<SWTheme style={appSWTheme}>
|
||||
<SWViewerHeader … /> {/* Dropdown·More 포함 */}
|
||||
<SWViewerPanel …>…</SWViewerPanel>
|
||||
</SWTheme>
|
||||
```
|
||||
2. 컴포넌트 하나만 쓰면 그 컴포넌트의 `style`로 정해도 됩니다(그 루트가 가장 바깥 범위). 안쪽 Dropdown·More·명령창에도 적용됩니다.
|
||||
3. **Portal:** 상속은 DOM 위치를 따릅니다. 프로그램이 `createPortal`로 SW 요소를 범위 밖(`document.body` 등)에 렌더링하면 값이 전달되지 않으므로, Portal 내용을 같은 theme 객체의 `<SWTheme style={appSWTheme}>`으로 감쌉니다(예제 [ExampleJ-Theme.tsx](./examples/ExampleJ-Theme.tsx)). SW 패키지 컴포넌트 자체는 Portal을 쓰지 않습니다(Dropdown·More·Panel 모두 범위 안에 렌더링).
|
||||
4. 영역마다 다른 값이 필요하면 영역별 `<SWTheme>`을 둡니다. 옆 영역끼리 섞이지 않고, 안쪽 `<SWTheme style>`은 그 안에서만 적용됩니다.
|
||||
5. **하지 않음:** `hm-sw-*` class를 선택자로 쓴 CSS(`.hm-sw-header, .hm-sw-theme { --… }` 등), `!important`, 안쪽 컴포넌트마다 같은 값을 반복 지정, SW 범위가 아닌 조상(`body`, 앱 루트 `div`)에만 토큰 지정(SW 범위가 기본값을 다시 선언하므로 적용되지 않음).
|
||||
- 다른 토큰을 참조하는 토큰(예: `--hm-sw-viewer-panel-background` = `var(--hm-sw-property-surface)`)은 가장 바깥 범위에서 계산됩니다. 참조되는 토큰을 바꿀 때도 가장 바깥 범위에서 바꿉니다.
|
||||
|
||||
## 4. 판단 기록 남기기
|
||||
|
||||
새 UI를 만들 때 결과 보고에 다음을 남긴다.
|
||||
|
||||
@@ -45,7 +45,7 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
|
||||
|
||||
| 컴포넌트 | 용도 | 주요 Props / Callback | 크기(Figma) | 상태 | 검증 |
|
||||
|---|---|---|---|---|---|
|
||||
| `SWTheme` | 단독 사용 범위(토큰·글꼴·초기화). 배치는 정하지 않음 | `div` HTML 속성, `className`, `style`(토큰 override) | — | — | ✅ |
|
||||
| `SWTheme` | 단독 사용 범위(토큰·글꼴·초기화). 배치는 정하지 않음 | `div` HTML 속성, `className`, `style`(토큰 override — 가장 바깥 SW 범위에서 지정하면 안쪽 Header·Dropdown·More·Panel·명령창이 상속, [AI_USAGE.md](./AI_USAGE.md) §3.1) | — | — | ✅ |
|
||||
| `SWHeaderHome` | 로고 슬롯 + 홈 버튼 | `logo`, `logoSrc`, `logoAlt`, `onClick`, `interactive`(false = 정적 로고) | 80 × 48, 로고 슬롯 32 × 17, 오른쪽 −17px로 다음 파일 면과 겹침 | Hover 배경 교체, Focus | ✅ |
|
||||
| `SWHeaderDocumentTitle` | 파일명 + 문서 Dropdown(+ New / Open) | `title`(필수), `documents`, `activeDocumentId`, `menuOpen`, `onMenuOpenChange`, `onDocumentSelect`, `onDocumentClose`, `onNew`, `onOpen`, `onClick`, `documentIcon`, `newLabel`, `openLabel`, `label`, `showNew` / `showOpen`(기본 true, 둘 다 false면 하단 없음) | 높이 48, 폭 190–310(반응형 clamp), 메뉴 310 | 텍스트만 Hover #FFC600, 열림 화살표 회전 | ✅ |
|
||||
| `SWHeaderLayerMenu` | 레이어 필드 + Dropdown | `value`(필수), `layers`(필수), `activeLayerId`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onLayerSelect`, `onVisibilityChange`, `onFreezeChange`, `onLockChange`, `onDetail`, `label`, `showFreeze` / `showLock`(기본 true. false = 그 상태를 그리지 않고 20px 자리만 유지) | 필드 234 × 38, 메뉴 234 | Hover 링, Selected, Expanded, 행 Hover | ✅ |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 0. 어떤 패턴을 고를까
|
||||
|
||||
먼저 SW 모드를 정합니다([AI_USAGE.md](./AI_USAGE.md) §0). 1~6과 8·10은 **SW 일반 모드**, 7은 **SW 뷰어 모드**이며 한 화면에서 섞지 않습니다.
|
||||
먼저 SW 모드를 정합니다([AI_USAGE.md](./AI_USAGE.md) §0). 1~6과 8·10은 **SW 일반 모드**, 7은 **SW 뷰어 모드**이며 한 화면에서 섞지 않습니다. 11(테마 설정)은 두 모드 공통입니다.
|
||||
|
||||
| 요청 | 모드 | 패턴 |
|
||||
|---|---|---|
|
||||
@@ -17,6 +17,7 @@
|
||||
| 뷰어·DWG/DXF 보기·도면 뷰어·읽기 전용 화면(Header 메뉴, 패널, 모달, 불러오기 상태) | 뷰어 | 7. 뷰어 모드 기본 조합 |
|
||||
| 일반 모드 `SWHeader`에서 로고를 누르면 열리는 2단 메뉴(저장·내보내기·찾아열기·최근파일 등) | 일반 | 8. 로고 클릭 메뉴 |
|
||||
| 화면 하단 명령 막대(Line·Arc 등 명령, 분류 탭, 보조기능 토글, 명령창), Model/Layout 탭 | 일반 | 10. Footer |
|
||||
| 글꼴 등 SW 토큰을 화면 전체(Header·Dropdown·More·Panel·명령창)에 같은 값으로 바꾸기, Portal 요소 포함 | 일반·뷰어 공통 | 11. 테마(토큰) 설정 |
|
||||
|
||||
## 1. 완성형 SW Header — [ExampleA-DefaultHeader.tsx](./examples/ExampleA-DefaultHeader.tsx)
|
||||
|
||||
@@ -224,9 +225,28 @@ const sections: SWHomeMenuSectionData[] = [
|
||||
4. 좁은 화면 처리(빈 칸 축소, ▲ 패널, 분류 ◀ ▶, 보조기능 아이콘만)는 `SWFooter`가 합니다. 소비 코드에서 명령 수를 줄이거나 media query로 다시 배치하지 않습니다.
|
||||
5. 일부만 필요하면(보조기능만, 분류 탭만) `<SWTheme>` 안에서 `SWFooterAuxGroup`·`SWFooterMenu`·`SWFooterToolButton`을 씁니다. `SWFooterMenu` 단독은 부모 면을 `var(--hm-sw-footer-group-background)`로 둡니다.
|
||||
|
||||
## 11. 테마(토큰) 설정 — [ExampleJ-Theme.tsx](./examples/ExampleJ-Theme.tsx)
|
||||
|
||||
일반·뷰어 모드 공통입니다. 토큰 기본값은 가장 바깥 SW 범위에서만 선언되고 안쪽 SW 요소는 상속합니다([AI_USAGE.md](./AI_USAGE.md) §3.1).
|
||||
|
||||
```tsx
|
||||
const appSWTheme = { "--hm-sw-header-font-family": '"Noto Sans KR", sans-serif' } as CSSProperties // 프로그램이 정한 값만
|
||||
|
||||
<SWTheme style={appSWTheme}> {/* 가장 바깥 범위: 여기서만 지정 */}
|
||||
<SWViewerHeader … /> {/* Dropdown·More까지 상속 */}
|
||||
<SWViewerPanel …>…</SWViewerPanel> {/* 패널·안쪽 컨트롤 상속 */}
|
||||
</SWTheme>
|
||||
|
||||
{createPortal(<SWTheme style={appSWTheme}>…</SWTheme>, document.body)} {/* Portal은 같은 객체로 다시 감쌈 */}
|
||||
```
|
||||
|
||||
- 일반 모드도 같습니다: `<SWTheme style={appSWTheme}>` 안의 `SWHeader`(Dropdown·창 이동 Tooltip)·`SWFooter`(`SWFooterCommandLine`·명령 등록 안내)가 같은 값을 씁니다.
|
||||
- 기본 글꼴은 Noto Sans KR입니다. Dotum 기본 적용은 디자인 승인 대기이므로 AI가 임의로 Dotum을 넣지 않습니다.
|
||||
- `hm-sw-*` class 선택자로 토큰을 덮거나 `!important`, 컴포넌트마다 같은 값 반복 지정을 하지 않습니다.
|
||||
|
||||
## 9. 하지 말 것
|
||||
|
||||
- `hm-sw-*` class를 소비 코드에 쓰거나 CSS로 덮어쓰기
|
||||
- `hm-sw-*` class를 소비 코드에 쓰거나 CSS로 덮어쓰기(토큰 변경도 class 선택자가 아니라 가장 바깥 SW 범위의 `style` — 11)
|
||||
- `dist` 구현·`styles.css` 블록 복사로 같은 컴포넌트 재작성
|
||||
- `@hm-design/sw/dist/...`·`src/...` 내부 경로 Import
|
||||
- 문서에 없는 Props 추측, 새 색·크기 값 도입
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
출처: `@hm-design/sw` `styles.css`(원본 `src/theme/sw.css`)의 토큰과 규칙, Figma 이지빔 SW `rj6wpsfk24lOWvWiVRAANP` node `26:8944`와 사용자 확정값. 이 문서에 없는 값은 확정값이 아닙니다. 새 컴포넌트에 임의 값을 만들지 말고 아래 토큰을 `var(--…)`로 읽으세요. 참고할 토큰·값이 없을 때만 [AI_USAGE.md](./AI_USAGE.md) §0.4 ③으로 구성하고 "AI 구성 값 — 디자인 확인 필요"로 보고합니다.
|
||||
|
||||
- 토큰은 `.hm-sw-header`와 `.hm-sw-theme`(`<SWTheme>`)에만 정의됩니다. 새 컴포넌트는 그 안에 렌더링합니다.
|
||||
- 기본값은 가장 바깥 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.
|
||||
@@ -43,7 +44,7 @@
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 글꼴 | Noto Sans KR → Malgun Gothic → Apple SD Gothic Neo (Figma Dotum 대체, 의도된 결정). 폰트 파일은 앱이 로드 |
|
||||
| 글꼴 | Noto Sans KR → Malgun Gothic → Apple SD Gothic Neo (`--hm-sw-header-font-family`, Figma Dotum 대체 — 현재 패키지 기본값). 폰트 파일은 앱이 로드. **Dotum 기본 적용 여부는 디자인 승인 대기**: 기본값을 바꾸지 않으며, 프로그램이 정한 경우에만 [AI_USAGE.md](./AI_USAGE.md) §3.1 방법으로 변경 |
|
||||
| 기본 / 라벨 | 14px / 12px (`--hm-sw-header-font-size` / `--hm-sw-header-label-size`) |
|
||||
| Weight | 400, 500, 700만 사용(600 없음). 값·제목·토글 700 |
|
||||
| 자간 | 값 0.04em, 라벨 0.02em, 파일명 0.06em |
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
|
||||
`entries`: `item`(`icon?`, `shortcut?`, `onSelect`), `checkbox`(`checked`, `onCheckedChange`), `radio`(`checked`, `onSelect`), `submenu`(`entries` — 다단계), `label`, `separator`. 공통 `disabled?`, `hidden?`, `keepOpen?`.
|
||||
|
||||
- **테마:** `SWViewerMenu`에는 `style` Prop이 없습니다. 토큰은 메뉴를 감싼 바깥 SW 범위(`SWViewerHeader`·`SWViewerPanel`·`<SWTheme>`)에서 상속됩니다. 단독 우클릭 메뉴의 값을 바꾸려면 메뉴를 `<SWTheme style={theme}>` 안에 둡니다([AI_USAGE.md](./AI_USAGE.md) §3.1).
|
||||
- Header의 `type: "menu"`가 이 Dropdown을 엽니다. 다른 위치(도면 우클릭 등)는 `SWViewerMenu`를 `open` / `onOpenChange` + `anchorRef`(버튼 아래 4px) 또는 `anchorPoint`(포인터 위치)로 씁니다. `surface="beige"`는 More 면, `initialFocus`는 `first`(키보드 열기) / `menu`(클릭 열기).
|
||||
- 키보드: Enter·Space·ArrowDown 열기, ↑↓ Home End 이동, → 서브메뉴 열기, ← 한 단계 닫기, Escape 닫고 Trigger로 Focus 복귀, Tab·바깥 클릭 닫기. 화면 끝에서는 위/왼쪽으로 뒤집혀 화면 안에 남습니다.
|
||||
|
||||
@@ -99,7 +100,8 @@ const groups: SWViewerHeaderGroup[] = [
|
||||
| 뷰어 화면에 로고(홈) 메뉴(`SWHomeMenu`·`SWRecentFiles`, `SWHeader homeMenu`) 넣기 또는 비슷한 2단 메뉴 직접 만들기 | 홈 메뉴는 SW 일반 모드 전용입니다. 뷰어 로고 클릭은 `onHomeClick`, 파일 열기·샘플은 `groups`의 `button`·`menu` 항목 |
|
||||
| 뷰어 Header를 `div` + `button`으로 다시 만들기, `hm-sw-viewer-*` class를 소비 코드에 붙이기 | 데이터(`groups`)와 `custom` / `trailing`으로 구성 |
|
||||
| Header 버튼 글자에 `-webkit-text-stroke`·`border`로 외곽선 만들기 | 외곽선은 패키지 토큰이 이미 적용합니다 |
|
||||
| 메뉴 색·간격을 `#123456`, `margin` 등 임의 값으로 덮기 | 토큰 `--hm-sw-viewer-*` / `--hm-sw-header-*`만 `style`로 조정(기본값 유지 권장) |
|
||||
| 메뉴 색·간격을 `#123456`, `margin` 등 임의 값으로 덮기 | 토큰 `--hm-sw-viewer-*` / `--hm-sw-header-*`만 가장 바깥 SW 범위(`<SWTheme>` 또는 단독 컴포넌트 루트)의 `style`로 조정(기본값 유지 권장, [AI_USAGE.md](./AI_USAGE.md) §3.1) |
|
||||
| Header·Dropdown·More·Panel의 글꼴을 각각 따로 지정, `.hm-sw-viewer-menu` 등 class로 덮기 | 화면을 `<SWTheme style={theme}>` 하나로 감싸면 Header·Dropdown·More·Panel이 같은 값을 상속합니다. 글꼴 기본값은 Noto Sans KR이며 Dotum 기본 적용은 디자인 승인 대기 |
|
||||
| 존재하지 않는 Props 추측(`items`, `content`, `renderBody` …) | 위 표와 `dist/index.d.ts`가 전부 |
|
||||
| 일반 모드 화면에서 `SWViewerHeader` 사용, 뷰어에서 `SWHeader` 재스타일 | 모드별 Header를 바꿔 끼웁니다 |
|
||||
| 기능 없는 메뉴를 가짜 동작으로 연결 | 항목 제외 또는 `disabled` + 미연결 보고 |
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Example J — SW theme tokens (both modes). The only official way to change SW tokens: `style` (or the program's own
|
||||
// class) on the OUTERMOST SW scope. Here one <SWTheme> wraps the viewer screen, so the header, its dropdowns, More,
|
||||
// and every panel read the same value. Portals leave the DOM tree: wrap their content in <SWTheme> with the same style.
|
||||
// The font below is a placeholder for a program decision: the SW default stays Noto Sans KR (a Dotum default is
|
||||
// pending design approval), so only set it when the program has decided to.
|
||||
import { useState, type CSSProperties } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import {
|
||||
SWTheme, SWViewerButton, SWViewerHeader, SWViewerHint, SWViewerPanel, SWViewerSection,
|
||||
type SWViewerHeaderGroup,
|
||||
} from "@hm-design/sw"
|
||||
|
||||
/** One theme object for the screen and for every portal. Token names only (`--hm-sw-*`), never `hm-sw-*` classes. */
|
||||
export const appSWTheme = {
|
||||
"--hm-sw-header-font-family": '"Noto Sans KR", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif',
|
||||
} as CSSProperties
|
||||
|
||||
export function ExampleJTheme({ onEvent }: { onEvent?: (name: string) => void }) {
|
||||
const [options, setOptions] = useState(false)
|
||||
const [notice, setNotice] = useState(false)
|
||||
const groups: SWViewerHeaderGroup[] = [
|
||||
{ id: "file", label: "파일", items: [
|
||||
{ type: "menu", id: "samples", label: "샘플", collapseBelow: 1180, entries: [
|
||||
{ type: "item", id: "sample-a", label: "기본 도면 · DWG", onSelect: () => onEvent?.("sample-a") },
|
||||
] },
|
||||
] },
|
||||
{ id: "panels", label: "패널", placement: "end", items: [
|
||||
{ type: "toggle", id: "options", label: "설정", pressed: options, collapseBelow: 900, onPressedChange: setOptions },
|
||||
{ type: "button", id: "notice", label: "알림", onSelect: () => setNotice(true) },
|
||||
] },
|
||||
]
|
||||
return (
|
||||
// Outermost SW scope: header, dropdown, More and panel inherit `appSWTheme`.
|
||||
<SWTheme style={appSWTheme}>
|
||||
<SWViewerHeader documentTitle="BasicSample.dwg" groups={groups} onCommand={command => onEvent?.(command.id)} />
|
||||
<SWViewerPanel title="옵션" open={options} onClose={() => setOptions(false)} width={320} height="auto">
|
||||
<SWViewerSection title="표시"><SWViewerHint>Header와 같은 토큰 값을 씁니다.</SWViewerHint></SWViewerSection>
|
||||
</SWViewerPanel>
|
||||
{/* A program portal renders outside the scope above: give it its own <SWTheme> with the same style. */}
|
||||
{notice && createPortal(
|
||||
<SWTheme style={{ ...appSWTheme, position: "fixed", right: 24, bottom: 24 }}>
|
||||
<SWViewerSection title="알림">
|
||||
<SWViewerHint>Portal 안에서도 같은 SW 토큰이 적용됩니다.</SWViewerHint>
|
||||
<SWViewerButton variant="primary" onClick={() => setNotice(false)}>확인</SWViewerButton>
|
||||
</SWViewerSection>
|
||||
</SWTheme>,
|
||||
document.body,
|
||||
)}
|
||||
</SWTheme>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/* @hm-design/sw — SW Footer (Figma "레이아웃 분리" 26:10097 bottom bar, components 26:9459 … 26:10097, read
|
||||
through the Figma Plugin API 2026-10-20). Footer-only tokens. Text keeps the SW typography of sw.css
|
||||
(Noto Sans KR instead of Dotum, black OUTSIDE outline as 16 text-shadows, own-color 0.3 / 0.4px strokes removed
|
||||
like the 2026-10-16 header fix). Never touches global selectors, SWHeader or SWPopup classes. */
|
||||
like the 2026-10-16 header fix). Never touches global selectors, SWHeader or SWPopup classes.
|
||||
Declared on the outermost SW scope only, like sw.css: the command line inside the bar inherits the tokens. */
|
||||
|
||||
.hm-sw-header,
|
||||
.hm-sw-theme {
|
||||
:is(.hm-sw-header, .hm-sw-theme):not(:where(.hm-sw-header, .hm-sw-theme) *) {
|
||||
/* Bar (26:10098 background, 72px high, #060606) */
|
||||
--hm-sw-footer-height: 72px;
|
||||
--hm-sw-footer-background: #060606;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* @hm-design/sw — SW viewer mode (reference: DWG/DXF viewer top menu and floating panels, measured 2026-10-19).
|
||||
Viewer-only tokens. The three header surfaces reuse the user-confirmed SW tokens of sw.css
|
||||
(--hm-sw-header-document-* / object-* / property-*). Never touches global selectors or the SWHeader. */
|
||||
(--hm-sw-header-document-* / object-* / property-*). Never touches global selectors or the SWHeader.
|
||||
Declared on the outermost SW scope only, like sw.css: nested viewer menus / panels inherit the tokens. */
|
||||
|
||||
.hm-sw-header,
|
||||
.hm-sw-theme {
|
||||
:is(.hm-sw-header, .hm-sw-theme):not(:where(.hm-sw-header, .hm-sw-theme) *) {
|
||||
/* Header */
|
||||
--hm-sw-viewer-height: 52px;
|
||||
--hm-sw-viewer-background: linear-gradient(180deg, #266651 0%, #123328 45%, #0a221d 100%);
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
SW-only tokens. They never touch @hm-design/ui tokens or global selectors. */
|
||||
|
||||
/* Tokens, typography and the control reset are shared by the full header and <SWTheme>,
|
||||
which scopes standalone sub-components without a consumer class. */
|
||||
.hm-sw-header,
|
||||
.hm-sw-theme {
|
||||
which scopes standalone sub-components without a consumer class.
|
||||
Token defaults are declared only on the OUTERMOST SW scope (2026-10-20): a nested scope root (viewer menu,
|
||||
More menu, panel, footer command line inside a header / footer / <SWTheme>) inherits the tokens instead of
|
||||
resetting them, so a token set through `style` (or the consumer's own class) on the outermost scope reaches
|
||||
every SW element inside it. Same specificity as before (0,1,0); the same rule is used in sw-viewer.css and
|
||||
sw-footer.css. */
|
||||
:is(.hm-sw-header, .hm-sw-theme):not(:where(.hm-sw-header, .hm-sw-theme) *) {
|
||||
/* Size */
|
||||
--hm-sw-header-height: 49px;
|
||||
--hm-sw-header-bar-height: 48px;
|
||||
@@ -171,7 +175,11 @@
|
||||
--hm-sw-header-input-hover-border-color: rgba(212, 203, 189, 0.25);
|
||||
--hm-sw-header-input-editing-background: #0f0d0a;
|
||||
--hm-sw-header-input-editing-border-color: rgba(212, 203, 189, 0.3);
|
||||
}
|
||||
|
||||
/* Every SW scope root (nested ones too) applies the typography from the inherited tokens. */
|
||||
.hm-sw-header,
|
||||
.hm-sw-theme {
|
||||
color: var(--hm-sw-header-text-color);
|
||||
font-family: var(--hm-sw-header-font-family);
|
||||
font-size: var(--hm-sw-header-font-size);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// SW theme token inheritance (2026-10-20, A-1).
|
||||
// 1) SW/Theme › Theme Inheritance: a token set through `style` on the outermost SW scope reaches the header, dropdowns,
|
||||
// More, panels, footer command line and tooltips; portals get it only when wrapped in a themed <SWTheme>; areas do
|
||||
// not mix; unthemed components keep Noto Sans KR. Dotum is a verification value only (not the SW default).
|
||||
// 2) Regression: every existing SW Story renders identically with the new cascade and with the legacy cascade
|
||||
// (token blocks re-declared on every `.hm-sw-header, .hm-sw-theme`, i.e. the code before this change): computed
|
||||
// font / color / geometry of every SW element and an element screenshot comparison. No Story sets an override,
|
||||
// so any difference would be a regression.
|
||||
// Usage: node packages/sw/tests/theme-inheritance.browser.mjs <storybook-url> <out-dir>
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
|
||||
const require = createRequire(path.join(runtime, 'index.js'))
|
||||
const { chromium } = require('playwright')
|
||||
const { PNG } = require('pngjs')
|
||||
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
|
||||
const [storybook = 'http://127.0.0.1:6024', outArg] = process.argv.slice(2)
|
||||
const out = path.resolve(outArg ?? 'docs/sw/theme-inheritance-qa')
|
||||
await mkdir(out, { recursive: true })
|
||||
|
||||
const VERIFY = 'Dotum, 돋움, sans-serif'
|
||||
const NESTED = '"Malgun Gothic", sans-serif'
|
||||
const DEFAULT = '"Noto Sans KR", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif'
|
||||
const results = []
|
||||
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) }
|
||||
const errors = []
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const newPage = async (width = 1440, height = 1000) => {
|
||||
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' })
|
||||
page.on('pageerror', error => errors.push(`${page.url()}: ${error.message}`))
|
||||
page.on('console', message => { if (message.type() === 'error' && !/favicon|404/.test(message.text())) errors.push(`${page.url()}: ${message.text()}`) })
|
||||
return page
|
||||
}
|
||||
const open = async (page, id) => {
|
||||
await page.goto(`${storybook}/iframe.html?id=${id}&viewMode=story`)
|
||||
await page.waitForSelector('#storybook-root > *')
|
||||
await page.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' })
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
// Font of every element with its own text under `selector` (header items, menu rows, panel controls …).
|
||||
const fonts = (page, selector, exclude = null) => page.evaluate(([selector, exclude]) => {
|
||||
const map = {}
|
||||
for (const root of document.querySelectorAll(selector)) for (const element of [root, ...root.querySelectorAll('*')]) {
|
||||
// Story content (fixture classes) and text-less range inputs are not SW typography.
|
||||
if (element.closest('svg') || element.matches('[class^="sw-"], input[type="range"]') || (exclude && element.matches(exclude))) continue
|
||||
const own = [...element.childNodes].some(node => node.nodeType === 3 && node.textContent.trim()) || /^(INPUT|TEXTAREA|BUTTON)$/.test(element.tagName)
|
||||
if (!own) continue
|
||||
const family = getComputedStyle(element).fontFamily
|
||||
map[family] = (map[family] ?? 0) + 1
|
||||
}
|
||||
return map
|
||||
}, [selector, exclude])
|
||||
const only = (map, expected) => Object.keys(map).length > 0 && Object.keys(map).every(family => family === expected)
|
||||
const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${family}`).join(' | ') || 'no text'
|
||||
|
||||
// ---------- 1. Theme inheritance Story ----------
|
||||
{
|
||||
const page = await newPage()
|
||||
await open(page, 'sw-theme--theme-inheritance')
|
||||
const scope = name => `[data-theme-case="${name}"]`
|
||||
|
||||
// Default (no theme): header, then its sample dropdown and More.
|
||||
const def = scope('default')
|
||||
check('기본: 단독 SWViewerHeader = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-header`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-header`)))
|
||||
await page.locator(`${def} .hm-sw-viewer-header [aria-haspopup="menu"]`).first().click()
|
||||
await page.waitForSelector(`${def} .hm-sw-viewer-menu`)
|
||||
check('기본: Dropdown = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-menu`)))
|
||||
await page.keyboard.press('Escape')
|
||||
await page.locator(`${def} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||||
await page.waitForSelector(`${def} .hm-sw-viewer-menu[data-surface="beige"]`)
|
||||
check('기본: More = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-menu`)))
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Viewer, themed through <SWTheme style>.
|
||||
const viewer = scope('viewer-themed')
|
||||
check('뷰어 테마: SWViewerHeader 전체 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-header`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-header`)))
|
||||
await page.locator(`${viewer} .hm-sw-viewer-header [aria-haspopup="menu"]`).first().click()
|
||||
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu`)
|
||||
check('뷰어 테마: Dropdown = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
|
||||
await page.keyboard.press('Escape')
|
||||
await page.locator(`${viewer} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||||
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu[data-surface="beige"]`)
|
||||
check('뷰어 테마: More = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
|
||||
// A submenu of More, if any, is a nested menu inside a nested menu.
|
||||
const sub = page.locator(`${viewer} .hm-sw-viewer-menu[data-surface="beige"] [aria-haspopup="menu"]`).first()
|
||||
if (await sub.count()) {
|
||||
await sub.hover()
|
||||
await page.waitForTimeout(150)
|
||||
check('뷰어 테마: More 서브메뉴 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
const panels = await page.locator(`${viewer} .hm-sw-viewer-panel`).count()
|
||||
check('뷰어 테마: SWViewerPanel(제목·입력·버튼·스위치·힌트) = Dotum', panels === 2 && only(await fonts(page, `${viewer} .hm-sw-viewer-panel`), VERIFY), `${panels} panels · ${fmt(await fonts(page, `${viewer} .hm-sw-viewer-panel`))}`)
|
||||
await page.screenshot({ path: path.join(out, 'viewer-themed-more.png'), clip: await page.locator(viewer).boundingBox() })
|
||||
|
||||
// General mode: SWHeader (+ open layer dropdown, window-move tooltip), SWFooter (+ command line, guide tooltip).
|
||||
const general = scope('general-themed')
|
||||
const headerFonts = await fonts(page, `${general} .hm-sw-header`, '.hm-sw-header__motion-tooltip')
|
||||
check('일반 테마: SWHeader + 열린 레이어 Dropdown = Dotum (창 이동 Tooltip 별도)', only(headerFonts, VERIFY), fmt(headerFonts))
|
||||
const tooltip = await page.locator(`${general} .hm-sw-header__motion-tooltip`).evaluate(element => getComputedStyle(element).fontFamily)
|
||||
check('일반 테마: 창 이동 Tooltip = Inter → 테마 글꼴', tooltip === `Inter, ${VERIFY}`, tooltip)
|
||||
const layerMenu = await page.locator(`${general} .hm-sw-header`).getByText('A-WALL').count()
|
||||
check('일반 테마: 레이어 Dropdown이 열린 상태(목록 행 A-WALL 표시)', layerMenu > 0, `${layerMenu} row`)
|
||||
check('일반 테마: SWFooter = Dotum', only(await fonts(page, `${general} .hm-sw-footer`), VERIFY), fmt(await fonts(page, `${general} .hm-sw-footer`)))
|
||||
check('일반 테마: 명령창(SWFooterCommandLine, 중첩 hm-sw-theme) = Dotum', only(await fonts(page, `${general} .hm-sw-footer-command`), VERIFY), fmt(await fonts(page, `${general} .hm-sw-footer-command`)))
|
||||
await page.locator(`${general} .hm-sw-footer-add`).first().focus()
|
||||
await page.waitForTimeout(150)
|
||||
const guide = page.locator(`${general} .hm-sw-footer [role="tooltip"]`)
|
||||
check('일반 테마: 명령 등록 안내(Tooltip) = Dotum', (await guide.count()) > 0 && only(await fonts(page, `${general} .hm-sw-footer [role="tooltip"]`), VERIFY), fmt(await fonts(page, `${general} .hm-sw-footer [role="tooltip"]`)))
|
||||
|
||||
// Portals.
|
||||
const [plain, themed, insideSource] = await page.evaluate(() => {
|
||||
const target = document.querySelector('[data-portal-target]')
|
||||
const plain = target.querySelector('[data-portal="unthemed"]'), themed = target.querySelector('[data-portal="themed"]')
|
||||
return [getComputedStyle(plain.querySelector('.hm-sw-viewer-button')).fontFamily, getComputedStyle(themed.querySelector('.hm-sw-viewer-button')).fontFamily,
|
||||
!!document.querySelector('[data-theme-case="portal-source"] [data-portal]')]
|
||||
})
|
||||
check('Portal: DOM이 테마 영역 밖에 렌더링됨', !insideSource)
|
||||
check('Portal: SWTheme만 감싸면 기본 글꼴(테마 미전달)', plain.startsWith('"Noto Sans KR"'), plain)
|
||||
check('Portal: 같은 theme style의 SWTheme으로 감싸면 테마 적용', themed === VERIFY, themed)
|
||||
|
||||
// Areas.
|
||||
const area = name => fonts(page, `${scope(name)} .hm-sw-viewer-hint`)
|
||||
check('영역 A(Dotum)', only(await area('area-a'), VERIFY), fmt(await area('area-a')))
|
||||
check('영역 B(기본) — 옆 영역 값이 섞이지 않음', only(await area('area-b'), DEFAULT), fmt(await area('area-b')))
|
||||
const outer = await page.locator(`${scope('area-c')} > .hm-sw-viewer-hint`).evaluate(element => getComputedStyle(element).fontFamily)
|
||||
check('영역 C 바깥(Dotum)', outer === VERIFY, outer)
|
||||
check('영역 C 안쪽 SWTheme style(Malgun Gothic)만 안쪽에 적용', only(await area('area-c-nested'), NESTED), fmt(await area('area-c-nested')))
|
||||
await page.screenshot({ path: path.join(out, 'theme-inheritance-full.png'), fullPage: true })
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- 2. Regression: new cascade vs legacy cascade on every existing SW Story ----------
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const legacy = (await Promise.all(['sw.css', 'sw-viewer.css', 'sw-footer.css'].map(file => readFile(path.join(here, '../src/theme', file), 'utf8'))))
|
||||
.map(text => {
|
||||
const start = text.indexOf(':is(.hm-sw-header, .hm-sw-theme):not(:where(.hm-sw-header, .hm-sw-theme) *) {')
|
||||
const end = text.indexOf('\n}', start)
|
||||
if (start < 0 || end < 0) throw new Error('token block not found')
|
||||
return `.hm-sw-header,\n.hm-sw-theme ${text.slice(text.indexOf('{', start), end + 2)}`
|
||||
}).join('\n')
|
||||
const index = await (await fetch(`${storybook}/index.json`)).json()
|
||||
const ids = Object.values(index.entries).filter(entry => entry.type === 'story' && /^sw-(header|popup|viewer|footer|home-menu)/.test(entry.id)).map(entry => entry.id)
|
||||
const snapshot = page => page.evaluate(() => [...document.querySelectorAll('.hm-sw-header, .hm-sw-theme')].flatMap(root => [root, ...root.querySelectorAll('*')]).map(element => {
|
||||
const style = getComputedStyle(element), rect = element.getBoundingClientRect()
|
||||
return `${style.fontFamily}|${style.fontSize}|${style.color}|${style.backgroundImage.slice(0, 80)}|${Math.round(rect.x * 10)},${Math.round(rect.y * 10)},${Math.round(rect.width * 10)},${Math.round(rect.height * 10)}`
|
||||
}))
|
||||
const regression = []
|
||||
for (const width of [1920, 1440, 1180, 900, 560]) for (const id of ids) {
|
||||
if (width !== 1440 && !/responsive|viewer--viewer-header|footer--default|overview/.test(id)) continue
|
||||
const page = await newPage(width)
|
||||
await open(page, id)
|
||||
const a = await snapshot(page)
|
||||
const shot = async () => PNG.sync.read(await page.screenshot({ fullPage: true, animations: 'disabled' }))
|
||||
const shotA = await shot()
|
||||
await page.waitForTimeout(150)
|
||||
// Pixels that change between two shots of the SAME CSS (live Story content) are masked out as noise.
|
||||
const shotA2 = await shot()
|
||||
await page.addStyleTag({ content: legacy })
|
||||
await page.waitForTimeout(150)
|
||||
const b = await snapshot(page)
|
||||
const shotB = await shot()
|
||||
const changed = a.length === b.length ? a.filter((value, i) => value !== b[i]).length : -1
|
||||
let pixels = -1, noise = 0
|
||||
if (shotA.width === shotB.width && shotA.height === shotB.height && shotA2.height === shotA.height) {
|
||||
pixels = 0
|
||||
for (let i = 0; i < shotA.data.length; i += 4) {
|
||||
const same = (x, y) => x[i] === y[i] && x[i + 1] === y[i + 1] && x[i + 2] === y[i + 2] && x[i + 3] === y[i + 3]
|
||||
if (!same(shotA.data, shotA2.data)) noise++
|
||||
else if (!same(shotA.data, shotB.data)) pixels++
|
||||
}
|
||||
}
|
||||
regression.push({ id, width, elements: a.length, changedElements: changed, diffPixels: pixels, maskedNoisePixels: noise, result: changed === 0 && pixels === 0 ? 'PASS' : 'FAIL' })
|
||||
await page.close()
|
||||
}
|
||||
// Negative control: the legacy cascade must reproduce the reported defect on the themed Story (the comparison above is
|
||||
// only meaningful if injecting it really restores the old behavior).
|
||||
{
|
||||
const page = await newPage()
|
||||
await open(page, 'sw-theme--theme-inheritance')
|
||||
await page.addStyleTag({ content: legacy })
|
||||
await page.waitForTimeout(150)
|
||||
const viewer = '[data-theme-case="viewer-themed"]'
|
||||
await page.locator(`${viewer} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||||
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu[data-surface="beige"]`)
|
||||
const more = await fonts(page, `${viewer} .hm-sw-viewer-menu`)
|
||||
const command = await fonts(page, '[data-theme-case="general-themed"] .hm-sw-footer-command')
|
||||
check('대조: 기존 구조 주입 시 More·명령창이 Noto로 되돌아감(결함 재현)', only(more, DEFAULT) && only(command, DEFAULT), `More ${fmt(more)} · 명령창 ${fmt(command)}`)
|
||||
await page.close()
|
||||
}
|
||||
const failedRegression = regression.filter(item => item.result !== 'PASS')
|
||||
check(`회귀: 기존 SW Story ${regression.length}개 화면 — 새 상속 구조 = 기존 구조(계산값·위치·픽셀)`, failedRegression.length === 0,
|
||||
`${regression.length - failedRegression.length}/${regression.length}${failedRegression.length ? ` FAIL ${failedRegression.map(item => `${item.id}@${item.width}(${item.changedElements}/${item.diffPixels})`).join(', ')}` : ''}`)
|
||||
check('Console / page error 없음', errors.length === 0, errors.slice(0, 5).join(' | '))
|
||||
|
||||
await browser.close()
|
||||
await writeFile(path.join(out, 'theme-inheritance.json'), JSON.stringify({ storybook, results, regression, errors }, null, 2) + '\n')
|
||||
const failed = results.filter(item => item.result !== 'PASS').length
|
||||
console.log(`${results.length - failed}/${results.length} PASS`)
|
||||
process.exit(failed ? 1 : 0)
|
||||
@@ -133,7 +133,7 @@
|
||||
| 패키지 | 스타일이 적용되는 범위 | 주의 |
|
||||
|---|---|---|
|
||||
| Web | `theme.css`의 토큰은 `:root, [data-hm-theme]`. `tailwind.css`는 Tailwind base에서 `*`(border·outline), `body`(배경·글자), `html`(글꼴)을 **문서 전체에** 적용. 앱이 import하는 `tailwindcss`의 Preflight도 전역 | Web CSS를 넣은 문서에서는 다른 영역에도 전역 초기화가 적용됨 |
|
||||
| SW(일반·뷰어 공통) | 토큰은 `.hm-sw-header`와 `.hm-sw-theme`에만 정의(뷰어 토큰 `--hm-sw-viewer-*`도 같은 범위). 전역 선택자 없음. `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`은 자기 root에 `hm-sw-theme`를 가짐 | 일반 모드의 Header 밖 SW 부품은 반드시 `<SWTheme>` 안 |
|
||||
| SW(일반·뷰어 공통) | 토큰은 `.hm-sw-header`와 `.hm-sw-theme`에만 정의(뷰어 토큰 `--hm-sw-viewer-*`도 같은 범위). 전역 선택자 없음. `SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`은 자기 root에 `hm-sw-theme`를 가짐. 토큰 기본값은 가장 바깥 SW 범위에서만 선언되고 안쪽 SW 범위는 상속(변경은 가장 바깥 범위의 `style`, SW `AI_USAGE.md` §3.1) | 일반 모드의 Header 밖 SW 부품은 반드시 `<SWTheme>` 안 |
|
||||
| OrgChart | Core가 자기 open Shadow DOM에만 CSS 로드. 호스트 CSS selector는 안쪽에 닿지 않고 **Custom Property 상속만** 통과 | 호스트 class로 카드 모양을 바꾸려 하지 않음 |
|
||||
|
||||
혼합 규칙:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState, type CSSProperties, type ReactNode } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import {
|
||||
SWFooter, SWFooterCommandLine, SWHeader, SWTheme, SWViewerButton, SWViewerHeader, SWViewerHint, SWViewerSection,
|
||||
} from "@hm-design/sw"
|
||||
import "@hm-design/sw/styles.css"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import { AUX_ITEMS, CATEGORIES, HISTORY } from "./sw-footer-fixture"
|
||||
import { LayerPanel, OptionsPanel, SAMPLES, useViewerDemo } from "./sw-viewer-fixture"
|
||||
import "./sw-stories.css"
|
||||
import "./sw-viewer-stories.css"
|
||||
|
||||
// SW theme token inheritance (2026-10-20). The official way to change SW tokens: set them through `style` (or the
|
||||
// consumer's own class) on the OUTERMOST SW scope — <SWTheme> around the screen, or the component root when it is
|
||||
// used alone. Nested SW roots (viewer menus, More, panels, footer command line) inherit the value.
|
||||
// Dotum is used here ONLY to make the inheritance visible: it is NOT the SW default font (design approval pending).
|
||||
const KICKER = "HM DESIGN SYSTEM / SW"
|
||||
const VERIFY_FONT = 'Dotum, "돋움", sans-serif'
|
||||
const VERIFY_THEME = { "--hm-sw-header-font-family": VERIFY_FONT } as CSSProperties
|
||||
const NESTED_THEME = { "--hm-sw-header-font-family": '"Malgun Gothic", sans-serif' } as CSSProperties
|
||||
|
||||
const meta = {
|
||||
title: "SW/Theme",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function Frame({ width, height, header, children, ...rest }: { width: number; height: number; header: ReactNode; children?: ReactNode; [data: `data-${string}`]: string }) {
|
||||
return (
|
||||
<div className="sw-story-scroller">
|
||||
<div className="sw-viewer-story-frame" style={{ width: "100%", maxWidth: width, height }} {...rest}>
|
||||
{header}
|
||||
<div className="sw-viewer-story-canvas" aria-label="도면 영역 (예시)">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ViewerArea({ width, panels, ...rest }: { width: number; panels?: boolean; [data: `data-${string}`]: string }) {
|
||||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: panels ? ["layers", "options"] : [] })
|
||||
return (
|
||||
<Frame width={width} height={panels ? 600 : 220} {...rest}
|
||||
header={<SWViewerHeader documentTitle={demo.documentName} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||
{panels && <>
|
||||
<LayerPanel demo={demo} strategy="absolute" defaultPosition="top-start" />
|
||||
<OptionsPanel demo={demo} strategy="absolute" />
|
||||
</>}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function PortalDemo() {
|
||||
const [target, setTarget] = useState<HTMLDivElement | null>(null)
|
||||
const content = (label: string) => (
|
||||
<SWViewerSection title={label}>
|
||||
<SWViewerHint>Portal로 테마 영역 밖에 렌더링한 SW 요소</SWViewerHint>
|
||||
<SWViewerButton>확인</SWViewerButton>
|
||||
</SWViewerSection>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
{/* The portals are created inside the themed scope, but their DOM lands in the target below (outside it). */}
|
||||
<SWTheme style={VERIFY_THEME} data-theme-case="portal-source">
|
||||
<SWViewerHint>이 영역(테마 지정)에서 아래 대상 영역으로 Portal을 만듭니다.</SWViewerHint>
|
||||
{target && createPortal(<SWTheme data-portal="unthemed">{content("SWTheme만 (테마 미전달 → 기본 글꼴)")}</SWTheme>, target)}
|
||||
{target && createPortal(<SWTheme style={VERIFY_THEME} data-portal="themed">{content("SWTheme + 같은 theme style (테마 전달)")}</SWTheme>, target)}
|
||||
</SWTheme>
|
||||
<div ref={setTarget} data-portal-target="" style={{ display: "grid", gridTemplateColumns: "repeat(2, 320px)", gap: 12, padding: 12, background: "#e4dbc9" }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ThemeInheritance: Story = {
|
||||
name: "Theme Inheritance",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader kicker={KICKER} title="SW — 테마 토큰 상속"
|
||||
description={<>SW 토큰은 가장 바깥 SW 범위(<code><SWTheme></code> 또는 단독 컴포넌트 루트)에서 한 번 선언되고, 안쪽 Dropdown·More·패널·명령창은 그 값을 상속합니다. 토큰은 가장 바깥 범위의 <code>style</code>로만 바꿉니다. 아래 Dotum은 <strong>상속 확인용 검증 값</strong>이며 SW 기본 글꼴(Noto Sans KR)은 바뀌지 않습니다(Dotum 기본 적용은 디자인 승인 대기).</>} />
|
||||
<StorySection title="1. 기본 (테마 지정 없음)" description="단독 SWViewerHeader. 기본 글꼴 Noto Sans KR. 1000px 폭이라 일부 항목이 More(⋯)로 이동합니다.">
|
||||
<ViewerArea width={1000} data-theme-case="default" />
|
||||
</StorySection>
|
||||
<StorySection title="2. 뷰어 모드 — SWTheme style로 글꼴 지정" description="<SWTheme style={{ '--hm-sw-header-font-family': … }}> 안의 Header·Dropdown·More·패널(내부 컨트롤 포함)이 같은 글꼴을 씁니다.">
|
||||
<SWTheme style={VERIFY_THEME} data-theme-case="viewer-themed">
|
||||
<ViewerArea width={1000} panels />
|
||||
</SWTheme>
|
||||
</StorySection>
|
||||
<StorySection title="3. 일반 모드 — SWHeader · SWFooter · 명령창" description="SWHeader Dropdown·창 이동 Tooltip, SWFooter의 명령창(SWFooterCommandLine)·명령 등록 안내(Tooltip)가 바깥 SWTheme의 값을 상속합니다.">
|
||||
<SWTheme style={VERIFY_THEME} data-theme-case="general-themed">
|
||||
<div className="sw-story-scroller" style={{ display: "grid", gap: 12, paddingBottom: 300 }}>
|
||||
<div style={{ width: 1440 }}>
|
||||
<SWHeader documentTitle="C0020101-006-평면및종단면도(06)" layer={{ name: "0_Layer_Cludmark" }} layerMenuOpen
|
||||
layers={[{ id: "0", name: "0_Layer_Cludmark" }, { id: "1", name: "A-WALL" }]} />
|
||||
</div>
|
||||
<div style={{ width: 1440, marginTop: 240 }}>
|
||||
<SWFooter categories={CATEGORIES} defaultCategoryId="modify" auxItems={AUX_ITEMS} onSlotAdd={() => {}}
|
||||
command={<SWFooterCommandLine history={HISTORY} expanded />} />
|
||||
</div>
|
||||
</div>
|
||||
</SWTheme>
|
||||
</StorySection>
|
||||
<StorySection title="4. Portal" description="Portal은 DOM 위치를 따르므로 테마 영역 밖으로 나간 요소에는 상속되지 않습니다. Portal 내용을 같은 theme style의 <SWTheme>으로 감싸면 같은 값이 적용됩니다.">
|
||||
<PortalDemo />
|
||||
</StorySection>
|
||||
<StorySection title="5. 여러 테마 영역" description="옆 영역끼리 값이 섞이지 않고, 안쪽 SWTheme의 style은 그 안에서만 적용됩니다.">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12 }}>
|
||||
<SWTheme style={VERIFY_THEME} data-theme-case="area-a"><SWViewerHint>영역 A — 검증 글꼴(Dotum)</SWViewerHint></SWTheme>
|
||||
<SWTheme data-theme-case="area-b"><SWViewerHint>영역 B — 기본(Noto Sans KR)</SWViewerHint></SWTheme>
|
||||
<SWTheme style={VERIFY_THEME} data-theme-case="area-c">
|
||||
<SWViewerHint>영역 C 바깥 — Dotum</SWViewerHint>
|
||||
<SWTheme style={NESTED_THEME} data-theme-case="area-c-nested"><SWViewerHint>영역 C 안쪽 — Malgun Gothic</SWViewerHint></SWTheme>
|
||||
</SWTheme>
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
Reference in new issue
Block a user