diff --git a/docs/ai/AI_GUIDE.md b/docs/ai/AI_GUIDE.md index b68d1e2..4213358 100644 --- a/docs/ai/AI_GUIDE.md +++ b/docs/ai/AI_GUIDE.md @@ -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 부품은 반드시 `` 안 | +| 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 부품은 반드시 `` 안 | | OrgChart | Core가 자기 open Shadow DOM에만 CSS 로드. 호스트 CSS selector는 안쪽에 닿지 않고 **Custom Property 상속만** 통과 | 호스트 class로 카드 모양을 바꾸려 하지 않음 | 혼합 규칙: diff --git a/docs/ai/MAINTAINING.md b/docs/ai/MAINTAINING.md index ba5b2e6..c7b123d 100644 --- a/docs/ai/MAINTAINING.md +++ b/docs/ai/MAINTAINING.md @@ -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`) | — | diff --git a/packages/org-chart-react/ai/AI_GUIDE.md b/packages/org-chart-react/ai/AI_GUIDE.md index b68d1e2..4213358 100644 --- a/packages/org-chart-react/ai/AI_GUIDE.md +++ b/packages/org-chart-react/ai/AI_GUIDE.md @@ -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 부품은 반드시 `` 안 | +| 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 부품은 반드시 `` 안 | | OrgChart | Core가 자기 open Shadow DOM에만 CSS 로드. 호스트 CSS selector는 안쪽에 닿지 않고 **Custom Property 상속만** 통과 | 호스트 class로 카드 모양을 바꾸려 하지 않음 | 혼합 규칙: diff --git a/packages/org-chart/ai/AI_GUIDE.md b/packages/org-chart/ai/AI_GUIDE.md index b68d1e2..4213358 100644 --- a/packages/org-chart/ai/AI_GUIDE.md +++ b/packages/org-chart/ai/AI_GUIDE.md @@ -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 부품은 반드시 `` 안 | +| 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 부품은 반드시 `` 안 | | OrgChart | Core가 자기 open Shadow DOM에만 CSS 로드. 호스트 CSS selector는 안쪽에 닿지 않고 **Custom Property 상속만** 통과 | 호스트 class로 카드 모양을 바꾸려 하지 않음 | 혼합 규칙: diff --git a/packages/sw/ai/AI_GUIDE.md b/packages/sw/ai/AI_GUIDE.md index b68d1e2..4213358 100644 --- a/packages/sw/ai/AI_GUIDE.md +++ b/packages/sw/ai/AI_GUIDE.md @@ -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 부품은 반드시 `` 안 | +| 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 부품은 반드시 `` 안 | | OrgChart | Core가 자기 open Shadow DOM에만 CSS 로드. 호스트 CSS selector는 안쪽에 닿지 않고 **Custom Property 상속만** 통과 | 호스트 class로 카드 모양을 바꾸려 하지 않음 | 혼합 규칙: diff --git a/packages/sw/ai/AI_USAGE.md b/packages/sw/ai/AI_USAGE.md index 5dc939b..dad4e62 100644 --- a/packages/sw/ai/AI_USAGE.md +++ b/packages/sw/ai/AI_USAGE.md @@ -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` 밖에서 하위 컴포넌트를 쓰면 반드시 ``로 감싼다. 토큰·글꼴·버튼 초기화가 이 범위에서만 적용된다. - 소비 코드는 `hm-sw-*` class를 붙이거나 재정의하지 않는다. 배치는 자기 class(`className`)와 `style`로 한다. - 뷰어 모드 컴포넌트(`SWViewerHeader`·`SWViewerMenu`·`SWViewerPanel`)는 자체 `hm-sw-theme` 범위를 가지므로 ``로 감싸지 않아도 된다. 소비 코드는 `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 범위**(``, 또는 단독으로 쓴 `SWHeader`·`SWViewerHeader`·`SWViewerPanel`·`SWViewerMenu`·`SWFooter`의 루트)에서 한 번만 선언됩니다. 그 안의 SW 요소 — Header의 Dropdown·More, `SWViewerMenu`, `SWViewerPanel`과 안쪽 컨트롤, `SWFooterCommandLine`, Tooltip·안내 — 는 자기 루트에 `hm-sw-theme`이 있어도 값을 다시 정하지 않고 **상속**합니다. + +1. 화면 전체(Header·Panel·Menu가 같은 값)를 바꾸려면 화면을 `` 하나로 감싸고 거기서만 토큰을 정합니다. 프로그램 class(`` + `.app-sw-theme { --hm-sw-header-font-family: … }`)도 됩니다. + ```tsx + const appSWTheme = { "--hm-sw-header-font-family": '"Noto Sans KR", sans-serif' } as CSSProperties + + {/* Dropdown·More 포함 */} + … + + ``` +2. 컴포넌트 하나만 쓰면 그 컴포넌트의 `style`로 정해도 됩니다(그 루트가 가장 바깥 범위). 안쪽 Dropdown·More·명령창에도 적용됩니다. +3. **Portal:** 상속은 DOM 위치를 따릅니다. 프로그램이 `createPortal`로 SW 요소를 범위 밖(`document.body` 등)에 렌더링하면 값이 전달되지 않으므로, Portal 내용을 같은 theme 객체의 ``으로 감쌉니다(예제 [ExampleJ-Theme.tsx](./examples/ExampleJ-Theme.tsx)). SW 패키지 컴포넌트 자체는 Portal을 쓰지 않습니다(Dropdown·More·Panel 모두 범위 안에 렌더링). +4. 영역마다 다른 값이 필요하면 영역별 ``을 둡니다. 옆 영역끼리 섞이지 않고, 안쪽 ``은 그 안에서만 적용됩니다. +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를 만들 때 결과 보고에 다음을 남긴다. diff --git a/packages/sw/ai/COMPONENT_CATALOG.md b/packages/sw/ai/COMPONENT_CATALOG.md index 6d3f98f..69c71ff 100644 --- a/packages/sw/ai/COMPONENT_CATALOG.md +++ b/packages/sw/ai/COMPONENT_CATALOG.md @@ -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 | ✅ | diff --git a/packages/sw/ai/COMPOSITION_PATTERNS.md b/packages/sw/ai/COMPOSITION_PATTERNS.md index 6d374c4..7fa710b 100644 --- a/packages/sw/ai/COMPOSITION_PATTERNS.md +++ b/packages/sw/ai/COMPOSITION_PATTERNS.md @@ -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. 일부만 필요하면(보조기능만, 분류 탭만) `` 안에서 `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 // 프로그램이 정한 값만 + + {/* 가장 바깥 범위: 여기서만 지정 */} + {/* Dropdown·More까지 상속 */} + … {/* 패널·안쪽 컨트롤 상속 */} + + +{createPortal(…, document.body)} {/* Portal은 같은 객체로 다시 감쌈 */} +``` + +- 일반 모드도 같습니다: `` 안의 `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 추측, 새 색·크기 값 도입 diff --git a/packages/sw/ai/DESIGN_RULES.md b/packages/sw/ai/DESIGN_RULES.md index 6987a64..c87eac5 100644 --- a/packages/sw/ai/DESIGN_RULES.md +++ b/packages/sw/ai/DESIGN_RULES.md @@ -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`(``)에만 정의됩니다. 새 컴포넌트는 그 안에 렌더링합니다. +- 기본값은 가장 바깥 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 | diff --git a/packages/sw/ai/VIEWER.md b/packages/sw/ai/VIEWER.md index 24702f4..073c554 100644 --- a/packages/sw/ai/VIEWER.md +++ b/packages/sw/ai/VIEWER.md @@ -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`·``)에서 상속됩니다. 단독 우클릭 메뉴의 값을 바꾸려면 메뉴를 `` 안에 둡니다([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 범위(`` 또는 단독 컴포넌트 루트)의 `style`로 조정(기본값 유지 권장, [AI_USAGE.md](./AI_USAGE.md) §3.1) | +| Header·Dropdown·More·Panel의 글꼴을 각각 따로 지정, `.hm-sw-viewer-menu` 등 class로 덮기 | 화면을 `` 하나로 감싸면 Header·Dropdown·More·Panel이 같은 값을 상속합니다. 글꼴 기본값은 Noto Sans KR이며 Dotum 기본 적용은 디자인 승인 대기 | | 존재하지 않는 Props 추측(`items`, `content`, `renderBody` …) | 위 표와 `dist/index.d.ts`가 전부 | | 일반 모드 화면에서 `SWViewerHeader` 사용, 뷰어에서 `SWHeader` 재스타일 | 모드별 Header를 바꿔 끼웁니다 | | 기능 없는 메뉴를 가짜 동작으로 연결 | 항목 제외 또는 `disabled` + 미연결 보고 | diff --git a/packages/sw/ai/examples/ExampleJ-Theme.tsx b/packages/sw/ai/examples/ExampleJ-Theme.tsx new file mode 100644 index 0000000..3b1d240 --- /dev/null +++ b/packages/sw/ai/examples/ExampleJ-Theme.tsx @@ -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 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 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`. + + onEvent?.(command.id)} /> + setOptions(false)} width={320} height="auto"> + Header와 같은 토큰 값을 씁니다. + + {/* A program portal renders outside the scope above: give it its own with the same style. */} + {notice && createPortal( + + + Portal 안에서도 같은 SW 토큰이 적용됩니다. + setNotice(false)}>확인 + + , + document.body, + )} + + ) +} diff --git a/packages/sw/src/theme/sw-footer.css b/packages/sw/src/theme/sw-footer.css index a1ad0d7..0b9d3c7 100644 --- a/packages/sw/src/theme/sw-footer.css +++ b/packages/sw/src/theme/sw-footer.css @@ -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; diff --git a/packages/sw/src/theme/sw-viewer.css b/packages/sw/src/theme/sw-viewer.css index a4c20ac..415a869 100644 --- a/packages/sw/src/theme/sw-viewer.css +++ b/packages/sw/src/theme/sw-viewer.css @@ -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%); diff --git a/packages/sw/src/theme/sw.css b/packages/sw/src/theme/sw.css index be8bb7f..94c6424 100644 --- a/packages/sw/src/theme/sw.css +++ b/packages/sw/src/theme/sw.css @@ -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 , - 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 / ) 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); diff --git a/packages/sw/tests/theme-inheritance.browser.mjs b/packages/sw/tests/theme-inheritance.browser.mjs new file mode 100644 index 0000000..35b5d96 --- /dev/null +++ b/packages/sw/tests/theme-inheritance.browser.mjs @@ -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 ; 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 +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 . + 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) diff --git a/packages/ui/ai/AI_GUIDE.md b/packages/ui/ai/AI_GUIDE.md index b68d1e2..4213358 100644 --- a/packages/ui/ai/AI_GUIDE.md +++ b/packages/ui/ai/AI_GUIDE.md @@ -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 부품은 반드시 `` 안 | +| 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 부품은 반드시 `` 안 | | OrgChart | Core가 자기 open Shadow DOM에만 CSS 로드. 호스트 CSS selector는 안쪽에 닿지 않고 **Custom Property 상속만** 통과 | 호스트 class로 카드 모양을 바꾸려 하지 않음 | 혼합 규칙: diff --git a/src/sw/sw-theme.stories.tsx b/src/sw/sw-theme.stories.tsx new file mode 100644 index 0000000..a389af1 --- /dev/null +++ b/src/sw/sw-theme.stories.tsx @@ -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 — 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 + +function Frame({ width, height, header, children, ...rest }: { width: number; height: number; header: ReactNode; children?: ReactNode; [data: `data-${string}`]: string }) { + return ( +
+
+ {header} +
{children}
+
+
+ ) +} + +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 ( + }> + {panels && <> + + + } + + ) +} + +function PortalDemo() { + const [target, setTarget] = useState(null) + const content = (label: string) => ( + + Portal로 테마 영역 밖에 렌더링한 SW 요소 + 확인 + + ) + return ( +
+ {/* The portals are created inside the themed scope, but their DOM lands in the target below (outside it). */} + + 이 영역(테마 지정)에서 아래 대상 영역으로 Portal을 만듭니다. + {target && createPortal({content("SWTheme만 (테마 미전달 → 기본 글꼴)")}, target)} + {target && createPortal({content("SWTheme + 같은 theme style (테마 전달)")}, target)} + +
+
+ ) +} + +export const ThemeInheritance: Story = { + name: "Theme Inheritance", + render: () => ( + + SW 토큰은 가장 바깥 SW 범위(<SWTheme> 또는 단독 컴포넌트 루트)에서 한 번 선언되고, 안쪽 Dropdown·More·패널·명령창은 그 값을 상속합니다. 토큰은 가장 바깥 범위의 style로만 바꿉니다. 아래 Dotum은 상속 확인용 검증 값이며 SW 기본 글꼴(Noto Sans KR)은 바뀌지 않습니다(Dotum 기본 적용은 디자인 승인 대기).} /> + + + + + + + + + + +
+
+ +
+
+ {}} + command={} /> +
+
+
+
+ + + + +
+ 영역 A — 검증 글꼴(Dotum) + 영역 B — 기본(Noto Sans KR) + + 영역 C 바깥 — Dotum + 영역 C 안쪽 — Malgun Gothic + +
+
+
+ ), +}