diff --git a/AGENTS.md b/AGENTS.md
index 69fbdaf..4a6c93b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,10 +4,10 @@
이 파일을 먼저 읽고 필요한 항목만 탐색한다. 전체 문서·전체 소스·전체 Storybook을 반복해서 읽지 않는다.
-## AI 조직도 독립 페이지 생성 — 공통 정책
+## AI 조직도 생성 — 공통 정책
-- 자연어 또는 JSON·CSV·Excel에서 **새 독립 조직도 페이지를 생성하는 작업**은 먼저 [AI 조직도 자동 생성 정책](docs/org-chart/ai-page-generation-rules.md)을 읽고 적용한다. 회사·조직 유형·Depth·인원수를 특정 사례의 기본값으로 고정하지 않는다.
-- Fixed·Auto/Expanded의 기본 출력은 **Chart Only: White Canvas + OrgChart Nodes + Connectors**다. 흰색 Root 캔버스·실제 Bounds 기준 가로/세로 중앙 정렬·외부 UI 기본 제외는 위 정책을 따른다. 제목·기준일·인원 요약·외부 Zoom/Fit 버튼·상태 문구·설명·회색 바깥 배경은 명시적 요청이 있을 때만 추가하며 기존 Core 내부 탐색 기능은 유지한다. AI 채팅·생성 입력·JSON·테스트·별도 통계·개발 안내와 Navigation은 요청 없이 추가하지 않는다. 이 범위에서는 아래 일반 업무 화면의 Navigation 의무 대신 해당 정책의 최소 화면 규칙을 따른다. 공개 API·Theme·React·접근성·검증 규칙은 계속 적용한다.
+- 자연어 또는 JSON·CSV·Excel에서 **새 조직도 컴포넌트 또는 전체 페이지를 생성하는 작업**은 먼저 [AI 조직도 자동 생성 정책](docs/org-chart/ai-page-generation-rules.md)을 읽고 적용한다. 생성 방식 미지정 시 코드 작성 전에 위 정책의 사전 선택 질문으로 Chart Only / Full Page를 확인하고 답변 후 구현한다. 이미 명시한 방식은 다시 묻지 않는다. 회사·조직 유형·Depth·인원수를 특정 사례의 기본값으로 고정하지 않는다.
+- **Chart Only**는 기존 페이지에 삽입 가능한 독립 컴포넌트로 흰색 캔버스·조직도 노드·연결선, 최초 Fit to View·마우스 휠 확대/축소·Pan을 제공하며 외부 제목·통계·설명·조작 패널을 생성하지 않는다. **Full Page**는 기존 HM UI를 우선 재사용해 요청된 제목·정보·필터·컨트롤과 전체 배치·반응형을 구성한다. 두 방식 모두 기존 OrgChart Core·React Wrapper와 정확한 요청 구조·인원·데이터를 사용한다. Fixed·Auto/Expanded는 생성 방식 선택을 대신하지 않는다. Chart Only에는 아래 일반 업무 화면의 Navigation 의무 대신 해당 정책의 최소 화면 규칙을 적용하고, Full Page에는 기존 HM 화면 계약을 적용한다. 공개 API·Theme·React·접근성·검증 규칙은 계속 적용한다.
- 기존 Full Composition·Expanded OrgChart·Department Colors·Overview·P9.5·P-AI1에는 소급 적용하지 않는다. 일반 업무 화면과 명시적인 기존 화면 수정에는 기존 규칙 및 해당 요청 범위를 유지한다.
1. 범위 `STORYBOOK_ONLY | PRODUCTION_UI | BOTH`, Runtime, 요구사항·필수 동작·완료 조건을 확인한다. 신규 AI 업무 화면은 실제 HM React 컴포넌트를 사용한다. 명시적 다른 Runtime adapter는 [상세 계약](docs/guidelines/runtime-portability.md)을 따른다.
diff --git a/CLAUDE.md b/CLAUDE.md
index 3f79954..79c6dfc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,7 @@ shadcn/ui(Radix, Tailwind v4, CVA) 기반 HM 디자인 시스템 시제품. Reac
규칙의 기준은 아래 문서이며, 이 파일에는 규칙 본문을 복사하지 않는다. 작업 전에 해당 문서를 읽는다.
- `AGENTS.md` — AI Implementation Defaults와 Navigation 필수 요약. 기존 Component / Storybook Reference 우선, Theme / Semantic 기본 적용, 단일 Navigation, 최소 Custom은 사용자의 별도 지시 없이 모든 신규·수정 UI에 적용한다.
-- [AI 조직도 자동 생성 정책](docs/org-chart/ai-page-generation-rules.md) — 자연어 또는 JSON·CSV·Excel에서 새 독립 조직도 페이지를 생성할 때 먼저 읽는다. 가변 계층·정확한 인원·제공 데이터 보존·기존 Core/React 재사용을 따른다. Fixed·Auto/Expanded 모두 **Chart Only: White Canvas + OrgChart Nodes + Connectors**를 기본으로 하며, 흰색 Root·중앙 배치·외부 UI 기본 제외와 Core 내부 탐색 기능 유지는 위 정책을 따른다. 이 범위의 Navigation 예외는 `AGENTS.md`의 조직도 전용 규칙을 따른다. 기존 Full Composition·Expanded OrgChart·Department Colors·Overview·P9.5·P-AI1에는 소급 적용하지 않는다.
+- [AI 조직도 자동 생성 정책](docs/org-chart/ai-page-generation-rules.md) — 자연어 또는 JSON·CSV·Excel에서 새 조직도 컴포넌트 또는 전체 페이지를 생성할 때 먼저 읽는다. 생성 방식 미지정 시 코드 작성 전에 위 정책의 사전 선택 질문으로 Chart Only / Full Page를 확인하고 답변 후 구현한다. 이미 명시한 방식은 다시 묻지 않는다. Chart Only는 흰색 캔버스·노드·연결선의 삽입용 컴포넌트, 최초 Fit·휠 Zoom·Pan과 외부 UI 제외를 따르고, Full Page는 기존 HM UI로 요청 UI·전체 배치·반응형을 구성한다. 두 방식 모두 가변 계층·정확한 인원·제공 데이터 보존·기존 Core/React 재사용을 따른다. Fixed·Auto/Expanded는 생성 방식 선택을 대신하지 않는다. Chart Only의 Navigation 예외와 Full Page의 HM 화면 계약은 `AGENTS.md`를 따른다. 기존 Full Composition·Expanded OrgChart·Department Colors·Overview·P9.5·P-AI1에는 소급 적용하지 않는다.
- 일반 업무 화면은 Storybook에 등록하지 않고 별도 React 화면·독립 미리보기로 제공한다. Storybook 수정은 Component/Pattern 또는 명시적 Storybook Reference 작업에 한정한다. 상세한 Storybook / 화면 생성 분리는 `AGENTS.md`와 `docs/guidelines/shadcn-기반-구현-원칙.md`를 따른다.
- `docs/guidelines/프로젝트-지침.md` — §6 컴포넌트·Storybook·CSS 작업 규칙 (모든 컴포넌트 작업 전 필독)
- `docs/guidelines/shadcn-기반-구현-원칙.md` — Component / Pattern / Story의 재사용 우선순위와 Custom 구현 허용 조건 (관련 작업 전 필독)
diff --git a/docs/org-chart/ai-page-generation-rules.md b/docs/org-chart/ai-page-generation-rules.md
index 46c166a..14951ce 100644
--- a/docs/org-chart/ai-page-generation-rules.md
+++ b/docs/org-chart/ai-page-generation-rules.md
@@ -1,14 +1,38 @@
# HMDesign — AI 조직도 페이지 자동 생성 공통 정책
-적용 대상은 Codex·Claude 등 AI 코딩 에이전트가 자연어 또는 제공 데이터에서 **새로 생성하는 독립 조직도 페이지**다. 회사, 조직 유형, 계층, Depth, 인원수에 종속되지 않는다. **고정형(Fixed)과 확장형(Auto/Expanded) 모두 동일한 화면 생성 원칙을 적용한다.** 기존 Full Composition·Expanded OrgChart·Department Colors·Overview의 승인된 화면은 변경하지 않으며, 기존 P9.5·P-AI1 결과에도 소급 적용하지 않는다.
+적용 대상은 Codex·Claude 등 AI 코딩 에이전트가 자연어 또는 제공 데이터에서 **새로 생성하는 삽입용 조직도 컴포넌트 또는 조직도 전체 페이지**다. 회사, 조직 유형, 계층, Depth, 인원수에 종속되지 않는다. **고정형(Fixed)과 확장형(Auto/Expanded) 모두 동일한 생성 방식 선택 원칙을 적용한다.** 기존 Full Composition·Expanded OrgChart·Department Colors·Overview의 승인된 화면은 변경하지 않으며, 기존 P9.5·P-AI1 결과에도 소급 적용하지 않는다.
## 1. 실행 방식과 적용 우선순위
- 사용자가 코딩 에이전트에 요청을 전달하면 에이전트가 조직 JSON과 실제 페이지 코드를 생성한다. 웹페이지에 AI 채팅창이나 생성 입력 UI를 추가하지 않는다. 브라우저 AI Provider 연결은 이 실행 방식의 전제가 아니다.
- 사용자 요청에서 명시한 UI 요구사항 → 이 정책의 AI 조직도 전용 범위 → 기존 OrgChart Core의 디자인·동작 규칙 순으로 적용한다. 충돌하지 않는 AGENTS.md·CLAUDE.md와 HM 공개 계약·데이터 검증·접근성 규칙은 계속 준수한다. 관련 문서는 필요한 부분만 읽는다.
-- 이 정책의 기본 출력은 **White Canvas + OrgChart Nodes + Connectors**인 Chart Only 화면이다. 일반 업무 화면의 GNB/LNB/DASHBOARD Navigation 의무를 가져와 메뉴나 Header를 추가하지 않는다. 사용자가 Navigation을 요청한 경우에만 기존 단일 Navigation 계약을 적용한다.
+- 생성 방식은 아래 사전 선택 절차로 확정한다. 미지정 요청을 Chart Only 또는 Full Page로 임의 결정하지 않는다. Chart Only에는 일반 업무 화면의 GNB/LNB/DASHBOARD Navigation 의무를 가져와 메뉴나 Header를 추가하지 않는다. Full Page의 요청 UI·Navigation은 기존 HM 화면 계약을 따른다.
- 기존 Core·React Wrapper·Theme·카드·Storybook을 요청 없이 변경하지 않는다. 필요한 공통 변경은 사유와 영향 범위를 먼저 보고한다. 새 조직도 엔진이나 디자인 시스템은 만들지 않는다.
+### 1.1 생성 방식 사전 선택
+
+Codex·Claude가 조직도 생성 요청을 받았을 때 **생성 방식이 명시되지 않았다면 코드 작성 전에 다음 질문을 먼저 하고, 사용자의 답변을 받은 뒤 구현한다.** 이 질문은 에이전트 대화에서 하며 웹페이지에 생성 방식 선택 UI를 추가하지 않는다.
+
+> 조직도만 기존 페이지에 삽입할까요, 아니면 레이아웃까지 구성한 새 페이지 전체를 생성할까요?
+
+**① 조직도만 생성 (Chart Only)**
+
+- 기존 페이지에 삽입 가능한 독립 조직도 컴포넌트를 생성한다.
+- 흰색 캔버스 및 조직도 노드·연결선만 표시한다.
+- 최초 Fit to View, 마우스 휠 확대·축소 및 Pan을 적용한다.
+- 외부 제목·통계·설명·조작 패널을 생성하지 않는다. 상세 구성은 §6의 Chart Only 규칙을 따른다.
+
+**② 새 페이지 전체 생성 (Full Page)**
+
+- 새로운 페이지의 레이아웃까지 설계하고 화면 전체의 배치 및 반응형 구성을 구현한다.
+- 기존 HMDesign UI 컴포넌트를 우선 재사용한다.
+- 사용자가 요청한 제목·정보·필터·컨트롤 등을 반영한다. 선택 자체를 요청하지 않은 부가 기능의 승인으로 확대하지 않는다.
+- 기존 OrgChart Core·React Wrapper로 조직도를 표시한다. 상세 구성은 §6.4를 따른다.
+
+사용자가 이미 생성 방식을 명시했다면 같은 선택 질문을 다시 하지 않고 해당 방식으로 바로 구현한다. 두 방식 모두 사용자 요청의 조직 구조·인원수·데이터를 정확히 반영하고 기존 OrgChart 컴포넌트를 재사용한다. Fixed·Auto/Expanded는 조직도 배치 모드이며 Chart Only·Full Page 선택을 대신하지 않는다.
+
+이미 Chart Only로 확정되어 진행 중인 구현에는 이 선택 절차를 다시 적용하거나 Full Page로 확대하지 않는다. 생성 방식 사전 선택 정책을 작성한 작업은 정책·참조 안내만 다뤘으며 진행 중인 구현·기존 Storybook 4개 화면은 수정하지 않았다. 별도로 승인된 2026-10-13 Feature 통합 범위와 결과는 [최종 Feature 통합 보고서](final-integration-2026-10-13.md)를 따른다.
+
## 2. 요청을 가변 구조로 정리
화면 구현 전에 다음 조건을 내부 생성 계획에 정리한다. 계획·가정·검증 결과는 작업 보고나 별도 파일에 기록하며 조직도 화면에 자동 삽입하지 않는다.
@@ -65,15 +89,15 @@ Depth에 회사나 직원 카드를 포함하는지, 조직별 인원이 직접
- 레이아웃·Depth·대표이사 표현·다중 회사·TB/LR/Hybrid·Zoom/Pan·접기·팝업은 실제 공개 API가 지원하는 범위만 사용한다. 직원 연결 방향을 전체 트리 방향 지원으로 해석하지 않는다.
- 요청 구조나 최소 화면을 공개 API로 정확히 표현할 수 없으면 지원 한계와 대안을 보고한다. 임의 구현·합성 화면을 기존 Core 결과라고 주장하지 않는다. 공통 기능 확장이 필요하면 별도 승인 범위로 분리한다.
-## 6. Chart Only 독립 페이지
+## 6. 선택한 생성 방식의 화면 구성
### 6.1 기본 출력과 제외 요소
-기본 결과물은 **White Canvas + OrgChart Nodes + Connectors**다. 기존 흰색 조직도 영역을 최상위 화면으로 사용한다.
+Chart Only 결과물은 **White Canvas + OrgChart Nodes + Connectors**인 독립 조직도 컴포넌트다. 기존 페이지에 삽입할 수 있으며, 독립 미리보기에서는 흰색 조직도 영역을 최상위 화면으로 사용한다.
- 회사·조직 그룹, 요청에 포함된 대표자·본부장·팀장·직원 카드, 연결선과 필요한 화살표, 회사별 컬러를 표시한다. 조직명·직위·직원 이름 등 조직도를 구성하는 필수 텍스트는 유지한다.
- 해당 모드에서 지원되는 기존 Core의 그룹 접기·펼치기, Zoom/Pan·직원 정보 보기 등 조직도 내부 탐색 기능은 유지한다. 외부 조작 UI의 기본 제외를 Core 기능 삭제로 해석하지 않는다.
-- 사용자가 명시적으로 요청한 요소만 아래 기본 제외 목록의 예외로 추가한다. 한 요소의 요청을 다른 부가 UI의 승인으로 확대하지 않는다.
+- 외부 제목·통계·설명·조작 패널은 Chart Only 컴포넌트에 생성하지 않는다. 새 페이지 레이아웃과 외부 UI 생성은 Full Page 범위로 구분하며, 한 요소의 요청을 다른 부가 UI의 승인으로 확대하지 않는다. 삽입 대상 기존 페이지의 UI를 삭제하거나 변경하지 않는다.
기본 제외 요소:
@@ -89,10 +113,10 @@ Depth에 회사나 직원 카드를 포함하는지, 조직별 인원이 직접
### 6.2 흰색 Root 캔버스와 초기 배치
-- 캔버스가 화면의 기본 Root 영역을 차지하며 배경은 흰색이다. 외부 회색 영역이나 불필요한 바깥 여백을 생성하지 않는다. 사용자가 지정한 캔버스 크기가 있으면 이를 우선한다.
+- 캔버스는 Chart Only 컴포넌트의 Root 영역을 차지하며 배경은 흰색이다. 독립 미리보기에서는 화면의 Root로 사용하고, 기존 페이지 삽입 시에는 호스트가 제공한 영역 안에 배치한다. 호스트 페이지의 배경·레이아웃을 덮어쓰지 않는다. 외부 회색 영역이나 불필요한 바깥 여백을 생성하지 않으며 사용자가 지정한 캔버스 크기가 있으면 이를 우선한다.
- 초기 배치는 조직도 전체의 실제 Bounds와 캔버스 크기를 기준으로 가로·세로 중앙 정렬한다. 노드·그룹·연결선을 포함한 전체 범위와 위치를 확인하며 임의 고정 좌표·배율을 회사별 템플릿으로 만들지 않는다.
-- 기본 배율은 조직 구조와 캔버스 크기를 고려해 지원되는 초기 자동 배치·Fit으로 결정한다. 대규모 조직도에서 전체 Fit만을 위해 텍스트를 지나치게 작게 만들지 않는다. 전체 개요와 확대 시 읽기, 내부 이동, 외부 가로 넘침을 함께 확인한다.
-- 기존 Zoom/Pan을 재사용하고 이를 위한 내부 이벤트 기능을 유지한다. 초기 Fit은 배치 동작이며 Fit to View 버튼 바를 렌더링해야 한다는 뜻이 아니다. 외부 컨트롤 버튼은 명시적 요청이 있을 때만 렌더링한다.
+- 최초 Fit to View로 조직도 전체의 실제 Bounds를 캔버스에 맞춘다. 대규모 조직도에서는 초기 전체 개요와 확대 시 텍스트 읽기, 내부 이동, 외부 가로 넘침을 함께 확인한다. 공개 API의 배율 한계로 전체 Fit이 불가능하면 제약을 보고한다.
+- 기존 Zoom/Pan을 재사용해 마우스 휠 확대·축소 및 Pan을 제공하고 내부 이벤트 기능을 유지한다. 초기 Fit은 배치 동작이며 Fit to View 버튼 바를 렌더링해야 한다는 뜻이 아니다. Chart Only에는 외부 컨트롤 버튼을 생성하지 않는다.
- 기존 카드·회사색·연결선·직원 팝업 디자인과 Theme를 유지한다. 캔버스 배경은 실제 선언된 공개 Theme·스타일 경로를 확인해 적용하며 없는 Token을 추측하지 않는다.
- 지원되는 공개 옵션으로 불필요한 내장 설명·요약·컨트롤을 제외한다. 옵션이 없거나 공개 API로 흰색 Root·중앙 배치를 구현할 수 없으면 제약을 보고한다. Shadow DOM 임의 조작, 공통 CSS 변경, Core·React Wrapper의 불필요한 공개 API 변경으로 해결하지 않는다.
- 최소 배율, 많은 직원, 긴 명칭, 깊은 계층, 여러 회사에서 전체 Fit과 가독성을 함께 만족할 수 없으면 잘림·읽기·탐색 한계를 보고한다. 이름·인원·Depth를 줄이거나 단순히 내용을 숨겨 잘림·레이아웃 문제를 감추지 않는다.
@@ -100,34 +124,43 @@ Depth에 회사나 직원 카드를 포함하는지, 조직별 인원이 직접
기본 구성은 다음과 같다.
- 기존 구성: Page Background → Header → Controls → Description → White Canvas → OrgChart
-- 앞으로 생성할 기본 구성: **White Canvas → OrgChart**
+- Chart Only 구성: **White Canvas → OrgChart**
### 6.3 Fixed / Auto 공통 적용과 기존 화면 보호
-- **Fixed:** 승인된 기존 카드·연결선 디자인과 해당 모드의 기존 배치를 재사용하되, 새 AI 생성 독립 페이지에는 Chart Only 구성을 적용한다.
-- **Auto / Expanded:** 기존 Core 자동 배치를 재사용하되, 조직도 외부의 설명·테스트·조작 패널을 제외한다.
-- 두 모드 모두 가변 계층·정확한 인원·제공 데이터 보존 규칙을 유지한다. 레이아웃 모드가 다르다는 이유로 추가 UI를 허용하지 않는다. 요청 데이터가 달라지면 계층과 렌더링도 그 데이터에서 달라져야 한다.
-- 새 페이지는 독립 React entry로 제공한다. 기존 Full Composition·Expanded OrgChart·Department Colors·Overview·Storybook 메뉴·P9.5·P-AI1 결과를 교체하지 않으며 기존 Storybook 페이지의 제목·컨트롤·설명 UI를 삭제하지 않는다.
+- **Fixed:** 승인된 기존 카드·연결선 디자인과 해당 모드의 기존 배치를 재사용하고, 선택한 Chart Only 또는 Full Page 범위를 적용한다. 현재 공개 Chart Only 옵션은 Auto 전용이며 Fixed와 함께 쓰면 거부된다. Fixed Chart Only가 필요하면 제약으로 보고하고 별도 승인 범위로 분리한다.
+- **Auto / Expanded:** 기존 Core 자동 배치를 재사용하고, Chart Only에서는 조직도 외부의 설명·테스트·조작 패널을 제외한다. Full Page에서는 요청된 외부 UI만 구성한다.
+- 두 배치 모드 모두 가변 계층·정확한 인원·제공 데이터 보존 규칙을 유지한다. 배치 모드가 다르다는 이유로 추가 UI를 허용하지 않는다. 요청 데이터가 달라지면 계층과 렌더링도 그 데이터에서 달라져야 한다.
+- Chart Only는 삽입용 독립 컴포넌트로, Full Page와 독립 미리보기는 독립 React entry로 제공한다. 기존 Full Composition·Expanded OrgChart·Department Colors·Overview·Storybook 메뉴·P9.5·P-AI1 결과를 교체하지 않으며 기존 Storybook 페이지의 제목·컨트롤·설명 UI를 삭제하지 않는다.
- Guard나 문서 분석기의 제약 때문에 요청하지 않은 Navigation·통계·설명 UI를 추가하지 않는다. 정책과 검사 도구가 충돌하면 원본 검사 결과와 원인을 보고하며 도구나 공통 계약을 이번 범위 밖에서 임의 수정하지 않는다.
+### 6.4 Full Page 전체 페이지
+
+- 기존 HMDesign Component·Pattern·Theme와 공개 API를 우선 재사용해 새 페이지의 전체 레이아웃을 구성한다. 기존 OrgChart Core·React Wrapper를 소비하며 새 조직도 엔진이나 카드 디자인을 만들지 않는다.
+- 사용자가 요청한 제목·정보·필터·컨트롤과 필수 동작을 반영한다. 요구사항에 없는 통계·설명·다운로드·외부 API·추가 메뉴·AI 채팅·생성 입력 UI는 자동으로 추가하지 않는다.
+- 조직도 캔버스와 요청 UI의 배치·크기·스크롤·반응형 동작을 함께 설계한다. Chart Only의 흰색 전체 Root·외부 UI 제외 규칙을 페이지 전체에 강제하지 않으며, 조직도 내부의 기존 디자인·탐색 기능은 유지한다.
+- 일반 업무 화면의 HM 컴포넌트 선택·Navigation·Theme·React bootstrap·접근성·Guard 계약은 AGENTS.md의 필수 규칙을 따른다. Full Page 선택만으로 기존 Storybook 화면 수정이나 공통 Component·Pattern·Theme 변경을 허용하지 않는다.
+
## 7. 생성 절차와 최종 통합 검증
+> Chart Only 구현(2026-10-13): Chart Only를 선택한 Auto 조직도는 공개 옵션 `layoutMode: 'auto'` + `presentationMode: 'chart-only'`로 생성한다. 초기 Fit·가로/세로 중앙 배치(`initialView: 'fit'`)와 Ctrl/⌘ 없는 커서 기준 휠 Zoom(`wheelZoom: 'direct'`)이 기본 적용되며 React·Vanilla가 같다. 외부 제목·요약·버튼·안내를 따로 만들지 않고 Shadow DOM을 조작하지 않는다. Fixed는 이 옵션을 거부하므로(TypeError) Fixed 요청을 임의로 Auto로 바꾸지 않고 Chart Only 미지원 제약으로 보고한다. 생성 방식의 사전 선택과 Full Page 범위는 계속 §1.1을 따른다. 사용법은 [공통 사용 가이드](org-chart-usage-guide.md#chart-only-공개-api-auto)를 따른다. 아래 P10.2 문단은 구현 전 기록이다.
+>
> P10.2 구현 한계 확인: 이 정책은 목표이며 현재 Core의 모든 내장 UI를 끄는 공개 옵션을 의미하지 않는다. 실제 Auto/Fixed 독립 검사는 Chart Only PARTIAL이다. Auto의 기본 초기 배치는 가로 중앙·상단 정렬이고, Vanilla의 공개 `fitToView()`는 전체 Bounds 기준 가로·세로 중앙 배치를 지원한다. React Wrapper에는 이를 호스트가 직접 호출할 공개 handle/ref가 없다. 지원 범위·후속 개선 후보는 [공통 사용 가이드](org-chart-usage-guide.md#chart-only-정책과-현재-기능의-관계)를 확인한다. 기존 화면·Core·공개 API를 변경하거나 내장 DOM을 숨겨 정책 완료로 보고하지 않는다.
-1. 요청을 분석하고 현재 작업 경로·Git 상태·기준 커밋·공개 API를 확인한다. 기존 미커밋 변경과 병렬 작업, 서버 포트를 보호한다.
+1. 코드 작성 전에 §1.1에 따라 생성 방식을 확인한다. 미지정이면 정해진 선택 질문에 대한 답변을 받은 뒤 진행하고, 이미 명시된 방식은 다시 묻지 않는다. 현재 작업 경로·Git 상태·기준 커밋·공개 API를 확인하고 기존 미커밋 변경과 병렬 작업, 서버 포트를 보호한다.
2. 회사별 계층·유형·Depth·조직 수·직원 수·조직장 집계 조건을 정리한다. 충돌하는 필수 조건만 확인한다.
3. 요청에서 가상 데이터를 생성하거나 제공 데이터를 보존해 변환한다. 계획과 변환 대응은 화면 밖에 기록한다.
4. 기존 Contract와 추가 정합성 검사를 통과시킨다. 이는 잘못된 데이터를 렌더러에 넘기지 않기 위한 필요한 사전 확인이다.
-5. 공개 Core·React Wrapper로 흰색 Root 캔버스에 조직도만 있는 Chart Only 독립 페이지를 생성한다. 실제 Bounds 기준 가로·세로 중앙 배치와 연결선을 확인한다. 새로운 구조를 고정된 사례로 바꾸지 않는다.
+5. 선택한 방식으로 구현한다. Chart Only는 흰색 캔버스·노드·연결선의 삽입용 컴포넌트와 최초 Fit·휠 Zoom·Pan을 제공한다. Full Page는 기존 HM UI와 OrgChart Core·React Wrapper로 요청 UI·전체 배치·반응형 페이지를 구성한다. 새로운 구조를 고정된 사례로 바꾸지 않는다.
6. 구현 후 변경 범위의 코드·diff·계층·인원·중복 집계·보존·핵심 동작을 한 번에 검증한다. 필요한 Typecheck·Build·Guard를 수행하고 실제 브라우저에서 확인한다. 이미 성공한 동일 조건 검사를 반복하지 않고 수정·실패 범위만 다시 확인한다.
-7. 390/768/1280/1440px와 사용자 지정 캔버스 크기에서 흰색 Root, 실제 Bounds 기준 초기 중앙 정렬·배율·가독성, 외부 Header·Controls·Description 및 상태 문구의 기본 제외, 콘텐츠/내부 스크롤, 줄바꿈, 카드·연결선·회사색, Zoom/Pan 내부 이벤트·접기·팝업, 키보드·포커스를 관련 사용 요소에 맞게 확인한다. 실제 실행 URL과 화면 캡처를 보고한다.
-8. 요청 대비 회사·조직·Depth·직원·조직장 집계, 생성 파일, 재사용 API, 가정, 제약, PASS/PARTIAL/FAIL와 NOT VERIFIED를 간결하게 보고한다. Guard 원본·정적·Runtime·STYLE 디자인 의도/최종 상태를 구분하고 미검증을 덮어쓰지 않는다.
+7. 390/768/1280/1440px와 사용자 지정 캔버스 크기에서 선택한 방식의 화면을 확인한다. Chart Only는 흰색 컴포넌트 Root·실제 Bounds 기준 최초 Fit과 중앙 배치·외부 UI 제외·호스트 삽입을, Full Page는 요청 UI·전체 배치·반응형·Navigation을 확인한다. 공통으로 배율·가독성·콘텐츠/내부 스크롤·줄바꿈·카드·연결선·회사색·Zoom/Pan 내부 이벤트·접기·팝업·키보드·포커스를 관련 사용 요소에 맞게 확인한다. 실제 실행 URL과 화면 캡처를 보고한다.
+8. 선택한 생성 방식, 요청 대비 회사·조직·Depth·직원·조직장 집계, 생성 파일, 재사용 API, 가정, 제약, PASS/PARTIAL/FAIL와 NOT VERIFIED를 간결하게 보고한다. Guard 원본·정적·Runtime·STYLE 디자인 의도/최종 상태를 구분하고 미검증을 덮어쓰지 않는다.
정책 작성·연결만 수행하는 작업에는 화면 구현·빌드·브라우저 실행·전체 회귀를 필수로 추가하지 않는다. 독립 실험 환경에서 선택적으로 화면을 검증할 수 있으나 기존 Storybook·AI Playground 테스트 페이지는 수정하지 않는다. 화면 검증을 수행하지 않았다면 **VISUAL NOT VERIFIED**로 보고한다. 데이터 생성·렌더링 기능을 실행하지 않았다면 정책 작성 완료를 범용 런타임 지원 PASS로 보고하지 않는다.
## 8. 범위 보호와 완료 기준
-- 기존 P9.5·P-AI1 산출물, P10 PARTIAL과 기존 Guard FAIL 10건의 검증 기록을 보존한다. 기존 P10.1 작업 폴더와 AI Playground Worktree를 수정하지 않으며 삼안 500명 테스트 페이지를 이관하지 않는다. 이번 정책 작성·연결 범위는 이 파일과 AGENTS.md·CLAUDE.md의 참조 안내이며 화면·데이터·Core 변경을 포함하지 않는다.
-- 이 정책을 참조한 생성 요청만으로 Stage·Commit·Push·Merge를 승인받은 것으로 간주하지 않는다. 현재 사용자의 작업별 Git 지시를 우선하며 이번 정책 작성에서는 해당 작업을 진행하지 않는다.
+- 기존 P9.5·P-AI1 산출물, P10 PARTIAL과 당시 Guard FAIL 10건의 검증 기록을 보존한다. 기존 P10.1 작업 폴더와 AI Playground Worktree를 수정하지 않으며 삼안 500명 테스트 페이지를 이관하지 않는다. 사전 선택 정책의 작성·연결 작업은 이 파일과 AGENTS.md·CLAUDE.md의 참조 안내만 다뤘으며 화면·데이터·Core 변경을 포함하지 않았다. 이후 2026-10-13 Feature 통합에는 Chart Only·초기 Fit·직접 Wheel Zoom, GuardFix 해결 5건, 생성 방식 사전 선택 정책만 포함한다. GuardFix는 F3–F7을 해결하며 잔여 FAIL 5건·NOT VERIFIED 9건과 원본 상태는 [최종 Feature 통합 보고서](final-integration-2026-10-13.md)에 구분해 기록한다. Cell 디자인·미리보기는 통합 대상에서 제외한다.
+- 이 정책을 참조한 생성 요청만으로 Stage·Commit·Push·Merge를 승인받은 것으로 간주하지 않는다. 현재 사용자의 작업별 Git 지시를 우선한다. 사전 선택 정책의 작성 작업에서는 Stage·Commit·Push·Merge를 진행하지 않았으며, 이후 2026-10-13 통합 요청은 위 3개 작업의 변경 범위 검증 후 Feature Commit·Push를 명시적으로 승인했다. main Merge·Docker 작업·npm Registry Publish는 승인 범위에 포함되지 않는다.
- 서로 다른 회사·대표이사 유무·조직 유형·Depth·분기 수·직원 수가 같은 요청 분석 → 데이터 변환 → Contract 검증 → Core 소비 절차로 처리돼야 한다. 특정 테스트 수치나 전용 템플릿에 의존하면 완료가 아니다.
- 생성 작업의 완료는 명시 조건의 일치, 제공 데이터 보존, 기존 Core의 실제 렌더링, 요청 범위의 최소 UI, 실행 증거로 판단한다. 미지원 구조를 지원한 것처럼 보고하거나 분석·JSON만으로 화면 생성 성공을 선언하지 않는다.
diff --git a/docs/org-chart/final-integration-2026-10-13.md b/docs/org-chart/final-integration-2026-10-13.md
new file mode 100644
index 0000000..bcf671b
--- /dev/null
+++ b/docs/org-chart/final-integration-2026-10-13.md
@@ -0,0 +1,60 @@
+# OrgChart 최종 Feature 통합 — 2026-10-13
+
+통합 범위는 **Chart Only + 초기 Fit + 직접 Wheel Zoom**, **GuardFix 해결 5건**, **AI 생성 방식 사전 선택 정책**이다. Cell 디자인 A/B안은 제외했다. 범위 검증·테스트는 PASS이며 전체 Guard는 잔여 FAIL 5건을 유지한다. Feature Commit·Push 완료 SHA와 최종 Working Tree는 이 작업의 최종 응답에서 보고한다.
+
+## 범위와 원본 보호
+
+- 대상: `D:\디자인시스템\HMDesign-OrgChart`, `feature/orgchart-package`, 기준 HEAD `0a8daf7ee7ac026952a2e1d3d5f25a922dab07cf`.
+- Chart Only 원본 Worktree(24개 승인 파일)와 GuardFix 원본(2개 파일)은 같은 기준 HEAD다. 원본 파일을 해시 snapshot으로 복사했고, 통합 뒤 원본 SHA-256이 모두 같음을 확인했다. 원본 Worktree에 수정·Stage·Commit하지 않았다.
+- 기존 미커밋 `AGENTS.md`, `CLAUDE.md`, `ai-page-generation-rules.md`의 사전 선택 변경을 보존했다. 겹치는 정책 문서는 전체 교체하지 않고 Auto 공개 API·Fixed 제약·현재 통합 범위만 합쳤다.
+- Cell 실험은 제품 Core에 적용된 적이 없다. 별도 `examples/org-chart-cell-preview`만 경로 검증 후 ignored `test-results/org-chart-cell-withdrawn-2026-10-13/preview`로 옮겼다. Cell 전용 스타일·미리보기·이미지는 Commit 대상에 포함하지 않는다. 기존 그룹 타이틀·직원 카드·연결선 디자인을 유지한다.
+- 회사 프리셋, 직원 카드, Fixed 승인 아트워크, 기존 Storybook 메뉴/Story, Theme/Token, Guard 분석기, JSON Contract, lockfile을 수정하지 않았다. `git reset --hard`, `git clean`, Force Push는 사용하지 않았다.
+- main Merge·Docker·npm Registry Publish는 실행하지 않는다.
+
+## 통합한 동작
+
+- Auto Core/React 공통 `presentationMode: 'chart-only'`는 흰색 캔버스·조직 노드·연결선만 표시한다. 내장 제목·기준일·선택 회사/인원 요약·Toolbar를 생성하지 않고 기존 접근성 live feedback는 시각적으로 숨긴다. Shadow DOM 소비자 강제 조작은 없다.
+- Chart Only의 기본은 `initialView: 'fit'`, `wheelZoom: 'direct'`다. 실제 Bounds를 최초 Fit·가로/세로 중앙 배치하며, 직접 휠 Zoom은 캔버스 안에서 포인터를 기준으로 적용한다. 바깥 휠은 페이지 스크롤을 유지한다.
+- 옵션을 생략한 기존 Expanded는 제목·요약·Toolbar·Modifier 휠·기본 중앙/상단 배치를 유지한다. Fixed Chart Only는 승인 디자인 보호를 위해 TypeError로 거부하며 임의로 Auto로 바꾸지 않는다.
+- React의 같은 값 inline 옵션 리렌더링에서 불필요한 재마운트/재Fit을 피하고, 데이터/회사/layout 교체에서는 새 Bounds로 Fit한다.
+- 통합 검토에서 발견한 2개 뷰 보존 문제를 같은 범위에서 수정했다. controller 재생성의 `initialPlacement` default가 명시적 undefined를 center로 바꾸던 문제를 제거했고, 내부 `fitScale()` getter로 기존 Fit floor를 전달한다. 전체 데이터 교체 시 이전 floor를 버리고 새 Fit을 계산한다. 계획된 공개 Props 3개 외 공개 API 확장은 없다.
+- AI 요청의 생성 방식이 미지정이면 코드 작성 전 Chart Only / Full Page를 질문하고 답변 후 구현한다. 이미 선택된 방식은 재질문하지 않는다. Fixed/Expanded 배치 선택은 생성 방식 선택을 대신하지 않는다.
+
+## GuardFix 5건과 남은 결과
+
+`department-colors.ts`의 미선언 contrast 후보 참조 3곳을 원래 fallback과 같은 `#10243b`/`#ffffff`로 교체했다. `expanded-content.ts`의 미소비 `--hm-orgchart-expanded-title-ink` setter와 그 안의 두 토큰 참조를 제거했다. 회사 primary·기존 흰색 제목 텍스트를 바꾸지 않는다. GuardFix 원본의 변경 전후 computed/geometry·캡처 hash 동일 증거도 확인했다.
+
+현재 동일 범위 Guard는 **F3–F7 해결, 기존 FAIL 10→5, 새 FAIL 0, NOT VERIFIED 9**다. 남은 F1/F2/F8/F9/F10은 다음 5개 TOKEN-001 위치다.
+
+- `department-colors.ts:47`: current-gradient / current-card-background.
+- `style-presets.ts:9`: resolved-bg-surface.
+- `style-presets.ts:74–75`: current-border / current-gradient.
+
+원본 `status/staticStatus = FAIL`, `runtimeStatus = NOT VERIFIED`를 보존한다. 원본 `styleEvidence`는 STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED이며 이 값으로 전체 Guard를 PASS로 바꾸지 않는다. 결합 styleVerification은 생성하지 않았다. 기존 P10/P10.3의 FAIL 10건 기록은 수정하지 않는다. `org-chart-content.css`의 추가 미선언 후보 참조까지 해결한 전체 패키지 검사는 이번 GuardFix 5건 범위가 아니다.
+
+실행: `node scripts/ai-generation-guard.mjs --target . --scope PARTIAL --profile STORYBOOK --files packages/org-chart/src/core/department-colors.ts,packages/org-chart/src/core/expanded-content.ts,packages/org-chart/src/core/style-presets.ts --json`. 결과는 ignored `_local/final-integration/guard-final.json`에 보존한다.
+
+## 최종 통합 검증
+
+- **PASS:** 루트 `tsc -b`, Core/React/독립 소비자 TypeScript 검사, 패키지 빌드, 독립 Chart Only production build, Storybook production build, Theme defaults 92 colors/94 defaults, `git diff --check`.
+- **PASS:** Core 86/86, Guard 회귀 94/94. viewport 수정 후 Core/관련 타입·빌드만 재검사했고 변경하지 않은 Guard 회귀는 반복하지 않았다.
+- **PASS:** 실제 통합 production 빌드의 React/Vanilla Chart Only **51/51**, 콘솔 오류 0. 20/100/500명·다중 회사·TB/LR/Hybrid·390px/1440px, 초기 Fit/중앙, 직접 휠의 포인터 고정·범위, Pan, 캔버스 밖 페이지 스크롤, 데이터 교체, popup Core/HM Dialog, 접기·직원 보존, 모바일 CDP touch를 확인했다.
+- **PASS:** 추가 5개 회귀는 미조작 초기 Fit 직후 접기/펼치기, Fit보다 높은 `zoom.min=0.8`로 재생성 후 축소 floor, Vanilla `setInteractions` 재생성 후 뷰 보존을 검사한다. 별도 `verify-min` query는 예제 검증용이며 제품 API/기본값 변경이 아니다.
+- **PASS:** 새 Storybook의 공개 메뉴 4개·Fixed 구성·Expanded 내장 UI·Fit 버튼 4개 검사는 첫 실행에서 통과했다. Modifier 휠 항목은 페이지 smooth scroll의 좌표 이동 때문에 실패했으므로 actual wheel 이벤트/배율로 진단하고 instant scroll로 테스트 좌표를 고정해 해당 항목만 재검사했다(1/1 PASS, 일반 휠 scale=1, Ctrl 휠=1.2214). 최초 결과 4/5와 재검사 결과를 별도 보존했다. 제품 휠 코드는 이 진단으로 변경하지 않았다.
+- 원본 Chart Only 작업의 기존 baseline DOM/픽셀 비교 9/9는 원본 작업 당시 증거다. 현재 통합의 5개 Storybook 동작 검사와 구분하며, 통합 후 전체 Storybook 픽셀 baseline 비교 완료를 주장하지 않는다.
+
+실제 증거: `test-results/org-chart-chart-only/results.json`, `storybook/results.json`, `storybook/results-wheel.json`, 캡처 PNG 및 `_local/final-integration/` 로그. 서버 네트워크 범위를 맞추기 전 connection timeout은 검사 실행 전의 도구 오류였으며, 실제 브라우저 실행 결과와 구분한다.
+
+Build는 성공했지만 기존 `:host-context(.dark)` 처리·bundle size 경고와 Storybook 기존 경고를 보존한다. 경고 해결을 위해 범위를 넓히지 않았다.
+
+**NOT VERIFIED / 기존 제약:** Fixed Chart Only, Safari/Firefox, 실제 모바일 기기·스크린리더, 2,100명 전체 회귀, 모든 회사·폭의 수동 시각 검토, 신규 npm tarball 외부 fresh install, 전체 baseline 픽셀 회귀. 기존 Fixed 390px 내부 스크롤 검사 실패는 원본 작업에서 기준 HEAD에도 재현된 기존 기록이며 이번 통합에서 해당 검사를 재실행하거나 수정하지 않았다.
+
+## 미리보기와 기록
+
+- React: `http://127.0.0.1:6045/react.html?data=100`
+- Vanilla: `http://127.0.0.1:6045/vanilla.html?data=100`
+- 통합 Storybook: `http://127.0.0.1:6046`
+
+Feature Push 대상은 공식 `https://gitea.hmac.kr/b25027/HMDesign.git`의 `feature/orgchart-package`다. 시작 시 원격 Feature는 기준 HEAD와 같고, 원격 main은 `c45d63a03b84eaac43f01bd400a16488a80e4229`였다. Commit/Push 직전에 다시 확인하며 원격 변경 시 덮어쓰지 않는다.
+
+작업 시작: 2026-10-13 18:18:29 KST. 최초 Estimated Duration: 20–30분. Commit·Push를 포함한 완료 시각/Actual Duration/Variance는 최종 응답에서 기록한다.
diff --git a/docs/org-chart/implementation-report-chart-only.md b/docs/org-chart/implementation-report-chart-only.md
new file mode 100644
index 0000000..061d0e9
--- /dev/null
+++ b/docs/org-chart/implementation-report-chart-only.md
@@ -0,0 +1,73 @@
+# HMDesign OrgChart — Chart Only 구현 보고서 (2026-10-13)
+
+RESULT: **PASS** (Auto/Expanded · React · Vanilla). Fixed Chart Only는 지원하지 않으며 제약으로 구분한다. Commit·Push·Merge는 하지 않았다.
+
+## 1. 작업 위치
+
+- Worktree: `D:\디자인시스템\HMDesign-OrgChart-ChartOnly-Impl`, 브랜치 `feature/orgchart-chart-only-implementation`, 기준 HEAD `0a8daf7ee7ac026952a2e1d3d5f25a922dab07cf`(변경은 미커밋).
+- 의존성은 이 Worktree에서 `npm ci --ignore-scripts`(lockfile 그대로)로 설치했다. 버전 변경 없음.
+
+## 2. UI 소유 위치 (STEP 1)
+
+| UI | 소유 | Chart Only 처리 |
+|---|---|---|
+| 제목·기준일(`createHeading`) | Core `expanded-content.ts` | 생성하지 않음 |
+| 선택 회사 수·인원 요약 | Core `index.ts` | 생성하지 않음 |
+| 확대/축소/100%/Fit 버튼·배율·조작 안내 | Core `expanded-viewport.ts` | toolbar 없이 같은 viewport 컨트롤러 사용 |
+| Drag & Drop 상태 문구 | Core `index.ts` | 요소는 유지(aria-live·aria-describedby), 화면에서는 시각적으로 숨김 |
+| 회색 패딩·테두리 외곽 | Core CSS `.hm-orgchart-expanded` | chart-only에서 흰색·패딩 0·테두리 0 |
+| Storybook 필터바·페이지 제목·설명 | Storybook Preview | 변경 없음(사용하지 않음) |
+| React Wrapper | 컨테이너 div와 선택형 Popup Adapter만 | 변경 없음 |
+
+기존 API만으로는 Vanilla `fitToView()` 호출까지 가능했고, 내장 UI 제거·React 초기 Fit·직접 휠 Zoom은 불가능했다.
+
+## 3. 추가한 공개 API (Core 옵션, React Props로 그대로 전달)
+
+- `presentationMode?: 'default' | 'chart-only'` — 기본 `'default'`. Auto 전용. Fixed와 함께 쓰면 `TypeError`.
+- `initialView?: 'center' | 'fit'` — chart-only 기본 `'fit'`, 그 외 `'center'`(기존 동작).
+- `wheelZoom?: 'modifier' | 'direct'` — chart-only 기본 `'direct'`, 그 외 `'modifier'`(기존 Ctrl/⌘ + 휠).
+- 타입 export: `OrgChartPresentationMode`, `OrgChartInitialView`, `OrgChartWheelZoom`.
+- React Wrapper: 세 옵션 전달, 객체 옵션(`autoLayout`·`interactions`·`selectedCompanyIds`·`zoom`·`companyTopNodes`·`employeeConnections`·`organizationLinks`·`aggregationPolicy`)을 값으로 비교해 같은 값의 inline 객체 리렌더링이 재마운트·재Fit을 일으키지 않게 했다. 마운트 직후 중복 `setSelectedCompanies` 호출도 사라졌다.
+- JSON Contract, `/hm-dialog`, `employeePopupAdapter`, 기본 Entry 의존성, 카드 SVG, 회사 컬러는 변경하지 않았다.
+
+## 4. 동작
+
+- 초기 Fit: 실제 Forest Bounds(조직·연결선 path 포함) 기준, 16px 여백, 최대 100%, 가로·세로 중앙. 측정 전에는 forest를 `visibility: hidden`으로 두어 첫 프레임이 튀지 않는다. 캔버스 크기 0이면 실행하지 않고 ResizeObserver/레이아웃 완료 경로에서 실행한다.
+- 재Fit: 최초 렌더링, `update`/`reload`/React `dataSource` 교체, 회사 선택·layout 변경. Zoom·Pan·팝업·그룹 접기·Drag & Drop 전환·단순 리렌더링에서는 재Fit하지 않는다.
+- 휠: 캔버스 안에서만 `preventDefault` 후 포인터 기준 Zoom. delta 정규화(line/page)와 이벤트당 ±120 제한. 캔버스 밖은 페이지 스크롤. `interactions.zoom=false`이면 휠을 가로채지 않는다.
+- Fit 배율이 `zoom.min`보다 작으면 휠 축소는 Fit 배율에서 멈춘다(`initialView: 'fit'`일 때만).
+
+## 5. 브라우저 검증 (실제 Chrome 155, Playwright 1.64)
+
+[`examples/org-chart-chart-only/verify.mjs`](../../examples/org-chart-chart-only/verify.mjs) **46/46 PASS, 콘솔 오류 0**. 증거: `test-results/org-chart-chart-only/`(ignored).
+
+| 데이터 (1440×900) | 배율 | 중앙 오차 X/Y | 상하좌우 여백 |
+|---|---|---|---|
+| 20명·3개 회사 | 1.0000 | 0 / 0 px | 174 / 29.75 px |
+| 100명·5개 회사 | 0.5135 | 0 / 0 px | 16 / 89.66 px |
+| 500명·삼안 6본부 17팀 | 0.3254 | 0 / 0 px | 231.58 / 16 px |
+| 100명 LR / Hybrid | 0.1819 / 0.5309 | 0 / 0 px | — |
+| 모바일 390×844 (20/100/500) | 0.3278 / 0.1306 / 0.1193 | 0 / 0 px | 페이지 가로 넘침 없음 |
+
+- React와 Vanilla의 배율·여백·휠·Pan 수치가 동일하다.
+- 일반 휠 위/아래 Zoom(0.3254→0.3974→0.3254), 포인터 아래 지점 화면 이동 0.14px, 최대 3·최소 0.05, Zoom 후 Pan 정확히 (120, −80)px, 600ms 뒤 재Fit 없음, Ctrl+휠도 캔버스 안에서만 동작(페이지 확대 없음).
+- 임베드 페이지: 캔버스 밖 휠은 페이지 스크롤(180px), 안쪽 휠은 스크롤 없이 Zoom.
+- 직원 팝업(Core·`/hm-dialog` HM Dialog) 열기/닫기, 그룹 접기·펼치기(500→472→500명) 후 배율·위치 유지. Drag & Drop 기본 OFF, 카드 드래그 후 소속 불변.
+- 데이터 전체 교체(500→100명) 후 다시 Fit·중앙(오차 0px). React 단순 리렌더링 후 사용자 뷰 유지.
+- 모바일 터치 Pan(CDP touch) 동작. 모바일에서 휠은 검증 대상이 아니다.
+
+## 6. 기존 화면 보호
+
+[`verify-storybook.mjs`](../../examples/org-chart-chart-only/verify-storybook.mjs) **9/9 PASS, 콘솔 오류 0**. 이 Worktree의 Storybook(6044)을 기존 Feature Storybook(6024, 같은 HEAD)과 비교했다.
+
+- 공개 OrgChart 메뉴 4개 유지(Overview, Department Colors, Full Composition, Expanded OrgChart).
+- 4개 페이지 모두 DOM 동일, 스크린샷 픽셀 동일. Full Composition은 한 실행에서 5픽셀 ±1 래스터 오차가 있었고 재실행에서 픽셀 동일이었다.
+- Expanded: 제목·기준일·요약·툴바 4버튼·배율·`드래그·방향키 이동 · Ctrl/⌘ + 휠 확대` 안내·Drag & Drop 문구 유지. 일반 휠은 Zoom하지 않고(페이지 스크롤), Ctrl+휠은 Zoom, Fit to View 버튼 동작.
+- 그 외: Core 단위 테스트 86/86, Guard 테스트 94/94(기준 HEAD와 동일), 루트 `tsc -b`, Core·React typecheck/build, Theme defaults check 통과.
+- 기존 `examples/org-chart-react/verify-boundaries.mjs`: 8 PASS / 1 FAIL이며 기준 HEAD 임시 Worktree에서도 같은 항목이 같은 결과로 실패한다(Fixed 390px 내부 가로 스크롤 검사, 기존 실패).
+
+## 7. 제약 · NOT VERIFIED
+
+- **Fixed Chart Only 미지원**: Workdesk 구성(제목·요약·아트워크·비례 축소)이 서로 묶여 있어 일부만 제거하면 승인 디자인이 바뀐다. 옵션을 함께 쓰면 오류로 알린다. Fixed에는 Auto의 휠 Zoom/Pan이 없다.
+- NOT VERIFIED: Safari/Firefox, macOS 트랙패드 핀치, 실제 모바일 기기, 스크린리더 낭독, 2,100명 전체 회귀(요청대로 반복하지 않음), `examples/org-chart-react/verify.mjs`(storybook-static 빌드 필요)·기존 P9.x 개별 스크립트.
+- React imperative ref는 추가하지 않았다(초기 Fit을 옵션으로 해결).
diff --git a/docs/org-chart/org-chart-usage-guide.md b/docs/org-chart/org-chart-usage-guide.md
index b198d98..0265655 100644
--- a/docs/org-chart/org-chart-usage-guide.md
+++ b/docs/org-chart/org-chart-usage-guide.md
@@ -417,7 +417,7 @@ nullable 필드는 생략하거나 빈 문자열로 대신하지 않는다. 필
- 측정된 배치는 `business-management/business-planning/talent-growth/integrated-system/design-planning/solution-sales` ID의 기존 열 구성에 맞는다. 상위 열이 매핑되지 않으면 일반 중앙 flow로 돌아가며 임의 데이터에 승인된 좌표 배치를 보장하지 않는다. 데이터의 ID/parentId를 디자인 매핑에 맞추려고 바꾸지 않는다.
- readOnly는 전체 펼침이며 Fixed interactive는 기존 접기 동작을 제공한다. Auto용 TB/LR/Hybrid·회사 선택·Drag & Drop·Zoom/Pan Handle은 Fixed의 새로운 기능이 아니다.
- **표시 데이터 제약:** `employee-card.ts`는 Workdesk 표현 카드의 이름을 `홍 길 동`으로 표시하고 Fixed 대표자 카드는 `대표이사` 직위를 표시하는 경로가 있다. 원본 Employee는 유지되지만 화면 표시가 원본 인명과 일치하지 않을 수 있다. 실제 사용자 데이터 표시에는 이를 보고하고 Auto를 검토한다. 이를 이번 문서 작업에서 수정하지 않는다.
-- readOnly가 제목·기준일·요약까지 제거하는 Chart Only prop은 아니다.
+- readOnly가 제목·기준일·요약까지 제거하는 Chart Only prop은 아니다. Chart Only는 Auto 전용 `presentationMode: 'chart-only'`이며 Fixed에서는 거부된다([Chart Only 공개 API](#chart-only-공개-api-auto)).
### Auto / Expanded — 데이터 기반 가변 조직도
@@ -589,7 +589,7 @@ export function UpdatingChart({ dataset }: { dataset: OrgChartDataset }) {
## 10. AI 코딩 에이전트 적용 절차
-1. 현재 사용자 요청·Git 기준·공개 API를 확인하고 회사/조직 유형·부모 관계·분기 수·직원 수를 정리한다. Root를 포함한 조직 Depth와 직원 수 집계 기준을 구분한다.
+1. [AI 페이지 생성 정책](ai-page-generation-rules.md#11-생성-방식-사전-선택)에 따라 생성 방식이 미지정이면 Chart Only / Full Page 선택 질문에 대한 답변을 받은 뒤 진행한다. 이미 명시된 방식은 다시 묻지 않는다. 현재 사용자 요청·Git 기준·공개 API를 확인하고 회사/조직 유형·부모 관계·분기 수·직원 수를 정리한다. Root를 포함한 조직 Depth와 직원 수 집계 기준을 구분한다.
2. 자연어의 가상 데이터를 생성하거나 제공 JSON·CSV·Excel을 Contract로 변환한다. Core가 CSV/XLSX parser나 자연어 생성기를 제공하는 것은 아니다. 외부 변환 결과는 unknown으로 취급한다.
3. 원본 이름·ID·소속·직위·사진·부모 관계를 보존한다. 조직장과 대표자를 총원에 포함할지 확인하고 employees에 한 번만 생성한다. 미지원 필드와 원본을 별도 보존하며 오류 항목을 삭제해 Validator를 통과시키지 않는다.
4. validateOrgChartDataset와 요청별 회사·조직·Depth·인원 검사를 수행한다. Auto 회사 프리셋/회사 경계/상단 직원 제약도 확인한다. Validator 성공만으로 Auto 표시가 보장되지 않는다.
@@ -602,7 +602,38 @@ export function UpdatingChart({ dataset }: { dataset: OrgChartDataset }) {
### Chart Only 정책과 현재 기능의 관계
-[AI 페이지 생성 정책](ai-page-generation-rules.md)을 먼저 읽고 AGENTS.md·CLAUDE.md의 연결을 따른다. **별도 요청이 없다면 AI 생성 화면에는 조직도 캔버스만 표시하고 추가 제목·안내·테스트·JSON 패널을 만들지 않는다.** 실제 적용 기준은 최종 통합된 정책이다.
+[AI 페이지 생성 정책](ai-page-generation-rules.md)을 먼저 읽고 AGENTS.md·CLAUDE.md의 연결을 따른다. **생성 방식이 미지정이면 코드 작성 전에 Chart Only / Full Page를 질문하고 답변 후 구현한다.** Chart Only를 선택하면 조직도 캔버스만 표시하고 외부 제목·통계·설명·조작 패널을 만들지 않는다. Full Page를 선택하면 기존 HM UI와 OrgChart Core·React Wrapper로 요청된 UI와 전체 레이아웃을 구성한다. 이미 명시된 방식은 다시 묻지 않으며 Fixed·Auto는 생성 방식 선택을 대신하지 않는다.
+
+#### Chart Only 공개 API (Auto)
+
+Auto(`layoutMode: 'auto'`)에서 `presentationMode: 'chart-only'`를 지정하면 Core가 **흰색 캔버스 + 조직 노드 + 연결선**만 렌더링한다. React와 Vanilla는 같은 Core 옵션을 사용한다. 기본값은 기존 화면과 동일하다(`'default'`).
+
+| 옵션 | 값 | 기본값 | 동작 |
+|---|---|---|---|
+| `presentationMode` | `'default' \| 'chart-only'` | `'default'` | chart-only는 외부 제목·기준일·회사/인원 요약·Zoom/Fit 버튼 바·배율·조작 안내·Drag & Drop 문구·카드형 외곽(회색 패딩·테두리)을 만들지 않는다. 노드 안 조직명·직원 정보는 유지한다. 이동 결과 live status는 화면에서 숨긴 채 보조기술에만 제공한다. |
+| `initialView` | `'center' \| 'fit'` | chart-only `'fit'`, 그 외 `'center'` | fit은 전체 Forest Bounds(연결선 포함)를 16px 여백 안에 담고 가로·세로 중앙에 둔다. 100%를 넘겨 확대하지 않는다. 측정 전에는 조직도를 숨겨 첫 화면이 튀지 않는다. |
+| `wheelZoom` | `'modifier' \| 'direct'` | chart-only `'direct'`, 그 외 `'modifier'` | direct는 캔버스 안에서 일반 휠로 포인터 기준 확대·축소한다. 캔버스 밖은 페이지 스크롤을 유지한다. modifier는 기존 Ctrl/⌘ + 휠이다. |
+
+```tsx
+// React — 기본 Entry, HMDesign UI 불필요. 컨테이너 높이는 호스트가 정한다.
+
+```
+
+```ts
+// Vanilla — 같은 Core 옵션
+createOrgChart({ container, dataSource, stylesheetUrls, layoutMode: 'auto', presentationMode: 'chart-only' }).mount();
+```
+
+- 캔버스는 컨테이너 높이를 채운다(`height: 100%`). 컨테이너 높이가 정해지지 않으면 최소 320px이다.
+- 자동 Fit 시점: 최초 렌더링, `update()`/`reload()`/React `dataSource` 교체, 회사 선택·Auto layout 변경. 휠 Zoom·Pan·직원 팝업·그룹 접기/펼치기·Drag & Drop ON/OFF·React 단순 리렌더링에서는 다시 Fit하지 않는다. 사용자가 조작하기 전 캔버스 크기가 바뀌면 다시 Fit하고, 조작한 뒤에는 사용자 뷰를 유지한다.
+- Fit 배율이 `zoom.min`보다 작을 수 있다(대규모 조직). 이때 휠 축소는 Fit 배율에서 멈추고, 확대는 `zoom.max`까지 가능하다. 카드 크기·글자 크기는 바꾸지 않는다.
+- 빈 공간 드래그 Pan, 방향키/`+`/`-`, 직원 팝업(기본 Core, `/hm-dialog` 선택형, `employeePopupAdapter`), 그룹 접기·펼치기, Drag & Drop 기본 OFF는 그대로다. 공개 `fitToView()`/`zoomTo()`/`panBy()` handle도 그대로 동작한다.
+- React Wrapper는 `autoLayout`·`interactions`·`selectedCompanyIds`·`zoom` 등 객체 옵션을 값으로 비교한다. 같은 값의 inline 객체로 리렌더링해도 다시 마운트하거나 Fit하지 않는다.
+- **Fixed는 지원하지 않는다.** `presentationMode: 'chart-only'`와 Fixed(`layoutMode` 생략 포함)를 함께 쓰면 `TypeError`를 던진다. 승인된 Workdesk 구성(제목·요약·아트워크·비례 축소)을 임의로 걷어내지 않기 위해서다. Fixed에 Auto와 같은 휠 Zoom/Pan도 없다.
+- 실행 예제와 브라우저 검증: [`examples/org-chart-chart-only`](../../examples/org-chart-chart-only/README.md).
+
+아래 P10.2 문단은 이 API가 생기기 전의 검증 기록이다.
P10.2에서 ChartOnly Worktree의 정책 보완을 통합했다. 기본 목표는 **White Canvas + OrgChart Nodes + Connectors**이며 외부 제목·기준일·요약·Zoom/Fit 버튼 바·설명·회색 배경은 요청 없이 추가하지 않는다. 정책은 생성 지침이며 새 Core prop/기능이 아니다. 현재 Auto는 heading/기준일·회사/인원 요약·Zoom/Fit toolbar·배율/조작 안내·Drag & Drop feedback를 내부 생성한다. Fixed readOnly도 heading·기준일과 기존 구성 요소를 유지한다.
@@ -628,10 +659,10 @@ React 없이 생성해 달라는 명시적 요청은 이 가이드의 Vanilla Co
### 주요 미지원·별도 확인 대상
- 자연어→데이터 자동 생성 UI, CSV/XLSX 직접 import, 서버 저장·외부 API·CSV/PDF/이미지 export는 공개 사용 기능이 아니다.
-- 임의 회사 프리셋 등록 setter·카드 교체·Compact·공개 collapse setter·React imperative ref·Chart Only 전용 옵션은 없다.
+- 임의 회사 프리셋 등록 setter·카드 교체·Compact·공개 collapse setter·React imperative ref는 없다. Chart Only는 Auto 전용 `presentationMode`로만 제공하며 Fixed Chart Only는 없다.
- 제공 데이터의 최대 인원/Depth·모든 브라우저·실제 모바일 터치/스크린리더·외부 fresh install을 이번 소스 분석으로 보장하지 않는다.
-- 기존 P10 PARTIAL/Guard FAIL 10건은 그대로다. 새 가이드의 문서 검사와 제품 전체 PASS를 혼동하지 않는다.
-- [P10.3 GuardReview](guard-review-p10-3.md)는 별도 Worktree에서 작성한 원본 분석이다. 기존 FAIL 10건을 재현했으며 제품·Guard 수정은 없다. P10.3 Git 공유 단계에서도 이 FAIL을 유지한다. 완전한 Chart Only 공개 API, 외부 CSS 테마 표준화, Fixed SVG/PNG 테마화, 디자인 승인, main Merge와 Docker 배포는 별도 후속 작업이다.
+- 기존 P10 PARTIAL/Guard FAIL 10건의 원본 기록은 보존한다. 2026-10-13 Feature 통합 대상은 Chart Only·초기 Fit·직접 Wheel Zoom, GuardFix의 F3–F7 해결 5건, AI 생성 방식 사전 선택 정책이다. Cell 디자인·미리보기는 제외한다. 잔여 Guard FAIL 5건·NOT VERIFIED 9건을 유지하며 원본 `status/staticStatus FAIL`, `runtimeStatus NOT VERIFIED`를 제품 전체 PASS로 표현하지 않는다. 범위와 새 결과는 [최종 Feature 통합 보고서](final-integration-2026-10-13.md)를 따른다.
+- [P10.3 GuardReview](guard-review-p10-3.md)는 별도 Worktree에서 작성한 원본 분석이다. 당시 FAIL 10건을 재현했고 제품·Guard 수정 없이 공유했다. 현재 Auto Chart Only는 위 공개 API로 지원하며 Fixed Chart Only, 외부 CSS 테마 표준화, Fixed SVG/PNG 테마화, 최종 디자인 승인, main Merge와 Docker 배포는 이번 통합 대상이 아니다.
### 이 가이드 예제 검증 범위
diff --git a/examples/org-chart-chart-only/README.md b/examples/org-chart-chart-only/README.md
new file mode 100644
index 0000000..7382443
--- /dev/null
+++ b/examples/org-chart-chart-only/README.md
@@ -0,0 +1,42 @@
+# OrgChart Chart Only example
+
+Standalone Vanilla and React consumers of the public Core option
+`presentationMode: 'chart-only'` (Auto only). Each page is one white canvas that
+fills the window: no title, as-of date, summary, toolbar or guidance. The chart
+opens fitted and centered, and the plain mouse wheel zooms at the pointer.
+Not a Storybook page.
+
+```bash
+npm run build:org-chart
+```
+
+```bash
+npm run build:ui
+```
+
+```bash
+npm run build:org-chart-react
+```
+
+```bash
+npx vite --config examples/org-chart-chart-only/vite.config.ts
+```
+
+Pages (port 6041): `vanilla.html`, `react.html` (default entry, Core popup) and
+`react-hm-dialog.html` (optional `/hm-dialog` entry with the HM theme).
+Query: `data=20|100|500` (20/100 are the existing multi-company fixtures, 500 is a
+virtual single-company dataset with 6 divisions and 5·2·4·1·3·2 teams),
+`layout=tb|lr|hybrid`, `embed=1` (verification only: a 480px canvas inside a
+scrolling page).
+
+Browser verification against the running server. Playwright and a browser are
+resolved through `ORGCHART_PLAYWRIGHT_MODULE` / `ORGCHART_CHROME` as in the other
+OrgChart checks; evidence goes to the ignored `test-results/org-chart-chart-only/`.
+
+```bash
+node examples/org-chart-chart-only/verify.mjs
+```
+
+`verify-storybook.mjs` checks the four public OrgChart Storybook menus against
+`STORYBOOK_URL` (default `http://127.0.0.1:6044`) and, with `BASELINE_URL`,
+compares DOM and pixels with a baseline Storybook.
diff --git a/examples/org-chart-chart-only/chart-only.css b/examples/org-chart-chart-only/chart-only.css
new file mode 100644
index 0000000..333e27d
--- /dev/null
+++ b/examples/org-chart-chart-only/chart-only.css
@@ -0,0 +1,6 @@
+/* Page layout only: the chart canvas fills the window. */
+html, body { height: 100%; margin: 0; background: #ffffff; }
+#chart { height: 100%; }
+/* Verification only: page content above and below a bounded canvas. */
+body.embed { height: auto; }
+body.embed #chart { height: 480px; margin: 600px 0 1400px; }
diff --git a/examples/org-chart-chart-only/data.ts b/examples/org-chart-chart-only/data.ts
new file mode 100644
index 0000000..65695f6
--- /dev/null
+++ b/examples/org-chart-chart-only/data.ts
@@ -0,0 +1,54 @@
+import type { Employee, OrgChartDataset, Organization } from '@hm-design/org-chart';
+import small from '../org-chart/data-expanded-small.json';
+import medium from '../org-chart/data-expanded-medium.json';
+
+/** Deterministic single-company dataset: 6 divisions with 5·2·4·1·3·2 teams.
+ * Virtual names only; no live data. Leaders are counted in `total`. */
+export function createSamanDataset(total = 500): OrgChartDataset {
+ const organizations: Organization[] = [];
+ const employees: Employee[] = [];
+ const add = (organizationId: string, position: string) => {
+ const id = `saman-e${String(employees.length + 1).padStart(3, '0')}`;
+ employees.push({ id, organizationId, affiliationId: 'saman', name: `가상직원 ${String(employees.length + 1).padStart(3, '0')}`, position, jobTitle: null, profileImage: null, workStatus: 'active' });
+ return id;
+ };
+ const org = (id: string, parentId: string | null, name: string, type: Organization['type'], displayOrder: number) => {
+ const node: Organization = { id, companyId: 'saman', parentId, name, type, leaderId: null, displayOrder };
+ organizations.push(node);
+ return node;
+ };
+ const root = org('saman-root', null, '삼안', 'root', 0);
+ root.leaderId = add(root.id, '대표이사');
+ const teams: Organization[] = [];
+ [5, 2, 4, 1, 3, 2].forEach((teamCount, d) => {
+ const division = org(`saman-d${d + 1}`, root.id, `가상 ${d + 1}본부`, 'division', d);
+ division.leaderId = add(division.id, '본부장');
+ for (let t = 0; t < teamCount; t++) {
+ const team = org(`saman-d${d + 1}-t${t + 1}`, division.id, `가상 ${d + 1}-${t + 1}팀`, 'team', t);
+ team.leaderId = add(team.id, '팀장');
+ teams.push(team);
+ }
+ });
+ let cursor = 0;
+ while (employees.length < total) add(teams[cursor++ % teams.length]!.id, ['사원', '선임', '책임', '수석'][cursor % 4]!);
+ return {
+ schemaVersion: '0.1-draft', organizations, employees, affiliations: [{ id: 'saman', name: '삼안' }], relations: [],
+ presentation: { title: '삼안 조직도 · 가상 데이터', description: null, hubText: null,
+ groups: { showEmployees: true, showSummary: false, showDispatched: false, showOnLeave: false, showAffiliations: false }, organizationOrder: [] },
+ metadata: { asOfDate: '2026-10-13', dataVersion: `chart-only-saman-${total}`, updateNote: '가상 데이터 · 운영 정보 아님' },
+ } as OrgChartDataset;
+}
+
+export const datasets: Record OrgChartDataset> = {
+ '20': () => structuredClone(small) as OrgChartDataset,
+ '100': () => structuredClone(medium) as OrgChartDataset,
+ '500': () => createSamanDataset(500),
+};
+
+export function readQuery() {
+ const query = new URLSearchParams(location.search);
+ const data = datasets[query.get('data') ?? ''] ? query.get('data')! : '20';
+ const layout = (['tb', 'lr', 'hybrid'] as const).find(type => type === query.get('layout')) ?? 'tb';
+ // embed=1 is a verification layout: a bounded canvas inside a scrolling page.
+ return { data, layout, embed: query.get('embed') === '1' };
+}
diff --git a/examples/org-chart-chart-only/hm-theme.css b/examples/org-chart-chart-only/hm-theme.css
new file mode 100644
index 0000000..840ff0b
--- /dev/null
+++ b/examples/org-chart-chart-only/hm-theme.css
@@ -0,0 +1,4 @@
+/* Optional HM Dialog integration only; the default entry needs none of this. */
+@import "@hm-design/ui/theme.css";
+@import "tailwindcss" source("./");
+@import "@hm-design/ui/tailwind.css";
diff --git a/examples/org-chart-chart-only/react-hm-dialog.html b/examples/org-chart-chart-only/react-hm-dialog.html
new file mode 100644
index 0000000..fa0554a
--- /dev/null
+++ b/examples/org-chart-chart-only/react-hm-dialog.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ OrgChart Chart Only · React HM Dialog
+
+
+
+
+
+
diff --git a/examples/org-chart-chart-only/react-hm-dialog.tsx b/examples/org-chart-chart-only/react-hm-dialog.tsx
new file mode 100644
index 0000000..9ca5ff8
--- /dev/null
+++ b/examples/org-chart-chart-only/react-hm-dialog.tsx
@@ -0,0 +1,20 @@
+import { StrictMode, useMemo } from 'react';
+import { createRoot } from 'react-dom/client';
+import { OrgChart } from '@hm-design/org-chart-react/hm-dialog';
+import { JsonOrgChartDataSource } from '@hm-design/org-chart';
+import engineUrl from '@hm-design/org-chart/engine.css?url';
+import coreUrl from '@hm-design/org-chart/style.css?url';
+import { datasets, readQuery } from './data';
+import './hm-theme.css';
+import './chart-only.css';
+
+const { data, layout } = readQuery();
+const stylesheetUrls = { engine: engineUrl, core: coreUrl };
+
+function ChartOnlyPage() {
+ const dataSource = useMemo(() => new JsonOrgChartDataSource(datasets[data]!()), []);
+ return ;
+}
+
+createRoot(document.getElementById('chart')!).render();
diff --git a/examples/org-chart-chart-only/react.html b/examples/org-chart-chart-only/react.html
new file mode 100644
index 0000000..36a0233
--- /dev/null
+++ b/examples/org-chart-chart-only/react.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ OrgChart Chart Only · React
+
+
+
+
+
+
diff --git a/examples/org-chart-chart-only/react.tsx b/examples/org-chart-chart-only/react.tsx
new file mode 100644
index 0000000..1a7cd24
--- /dev/null
+++ b/examples/org-chart-chart-only/react.tsx
@@ -0,0 +1,31 @@
+import { StrictMode, useMemo, useState } from 'react';
+import { createRoot } from 'react-dom/client';
+import { OrgChart } from '@hm-design/org-chart-react';
+import { JsonOrgChartDataSource } from '@hm-design/org-chart';
+import engineUrl from '@hm-design/org-chart/engine.css?url';
+import coreUrl from '@hm-design/org-chart/style.css?url';
+import { datasets, readQuery } from './data';
+import './chart-only.css';
+
+const { data, layout, embed } = readQuery();
+if (embed) document.body.classList.add('embed');
+const stylesheetUrls = { engine: engineUrl, core: coreUrl };
+let replace: (key: string) => void = () => {};
+let rerender: () => void = () => {};
+
+function ChartOnlyPage() {
+ const [key, setKey] = useState(data);
+ const [, setTick] = useState(0);
+ replace = setKey;
+ rerender = () => setTick(value => value + 1);
+ const dataSource = useMemo(() => new JsonOrgChartDataSource(datasets[key]!()), [key]);
+ // Inline option literals are intentional: equal values must not refit on re-render.
+ return ;
+}
+
+createRoot(document.getElementById('chart')!).render();
+// Verification seam only.
+Object.assign(window, { replaceData: (key: string) => replace(key), rerenderPage: () => rerender() });
diff --git a/examples/org-chart-chart-only/tsconfig.json b/examples/org-chart-chart-only/tsconfig.json
new file mode 100644
index 0000000..70bf37e
--- /dev/null
+++ b/examples/org-chart-chart-only/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.app.json",
+ "compilerOptions": { "composite": false, "noEmit": true, "types": ["vite/client"], "resolveJsonModule": true, "tsBuildInfoFile": "../../_local/org-chart-chart-only.tsbuildinfo" },
+ "include": ["*.ts", "*.tsx"]
+}
diff --git a/examples/org-chart-chart-only/vanilla.html b/examples/org-chart-chart-only/vanilla.html
new file mode 100644
index 0000000..34af260
--- /dev/null
+++ b/examples/org-chart-chart-only/vanilla.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ OrgChart Chart Only · Vanilla
+
+
+
+
+
+
diff --git a/examples/org-chart-chart-only/vanilla.ts b/examples/org-chart-chart-only/vanilla.ts
new file mode 100644
index 0000000..78a832a
--- /dev/null
+++ b/examples/org-chart-chart-only/vanilla.ts
@@ -0,0 +1,23 @@
+import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart';
+import engineUrl from '@hm-design/org-chart/engine.css?url';
+import coreUrl from '@hm-design/org-chart/style.css?url';
+import { datasets, readQuery } from './data';
+import './chart-only.css';
+
+const { data, layout, embed } = readQuery();
+if (embed) document.body.classList.add('embed');
+const chart = createOrgChart({
+ container: document.getElementById('chart')!,
+ dataSource: new JsonOrgChartDataSource(datasets[data]!()),
+ stylesheetUrls: { engine: engineUrl, core: coreUrl },
+ displayMode: 'readOnly',
+ layoutMode: 'auto',
+ autoLayout: { type: layout },
+ // Chart Only implies initialView "fit" and wheelZoom "direct".
+ presentationMode: 'chart-only',
+ // Regression fixture: Fit below the manual zoom floor, without huge data.
+ zoom: new URLSearchParams(location.search).has('verify-min') ? { min: 0.8 } : undefined,
+});
+void chart.mount();
+// Verification seam only: replace the whole dataset through the public handle.
+Object.assign(window, { orgChart: chart, replaceData: (key: string) => chart.update(datasets[key]!()) });
diff --git a/examples/org-chart-chart-only/verify-storybook.mjs b/examples/org-chart-chart-only/verify-storybook.mjs
new file mode 100644
index 0000000..89d2d0d
--- /dev/null
+++ b/examples/org-chart-chart-only/verify-storybook.mjs
@@ -0,0 +1,154 @@
+// Storybook regression for the Chart Only change. Compares this worktree's
+// Storybook (STORYBOOK_URL) with a baseline Storybook (BASELINE_URL, optional)
+// and checks that Expanded keeps its controls and Ctrl/⌘ + wheel policy.
+import assert from 'node:assert/strict';
+import path from 'node:path';
+import { mkdir, writeFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
+
+const root = fileURLToPath(new URL('../../', import.meta.url));
+const output = path.join(root, 'test-results/org-chart-chart-only/storybook');
+await mkdir(output, { recursive: true });
+const target = process.env.STORYBOOK_URL ?? 'http://127.0.0.1:6044';
+const baseline = process.env.BASELINE_URL;
+const browser = await launchChromium();
+const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
+const results = [];
+const consoleErrors = [];
+const wheelOnly = process.argv.includes('--wheel-only');
+const record = (name, pass, detail) => { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}`, detail === undefined ? '' : JSON.stringify(detail)); };
+const check = async (name, fn) => { try { record(name, true, await fn()); } catch (error) { record(name, false, error.message); } };
+const stories = ['orgchart--logo-personnel-summary', 'orgchart--department-colors', 'orgchart--full-composition', 'orgchart--expanded-org-chart'];
+
+async function open(origin, id) {
+ const page = await context.newPage();
+ page.on('console', message => { if (message.type() === 'error') consoleErrors.push({ origin, id, text: message.text() }); });
+ page.on('pageerror', error => consoleErrors.push({ origin, id, text: String(error) }));
+ await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`);
+ await page.waitForFunction(() => {
+ const hosts = [...document.querySelectorAll('.hm-orgchart-host')];
+ // Overview renders summaries without an OrgChart host.
+ return document.getElementById('storybook-root')?.childElementCount && hosts.every(host => host.shadowRoot?.querySelector('.hm-orgchart-shell')?.getAttribute('aria-busy') === 'false');
+ }, null, { timeout: 60000 });
+ await page.evaluate(() => document.fonts.ready);
+ await page.waitForTimeout(800);
+ return page;
+}
+/** Story DOM plus every OrgChart shadow root, without instance/React IDs. */
+const snapshot = page => page.evaluate(() => [document.getElementById('storybook-root').innerHTML,
+ ...[...document.querySelectorAll('.hm-orgchart-host')].map(host => host.shadowRoot.innerHTML)].join('\n')
+ .replace(/hm-orgchart-\d+/g, 'hm-orgchart-N').replace(/«[^»]*»|_r_[0-9a-z]+_/g, 'react-id'));
+
+async function pixelDiff(a, b) {
+ const page = await context.newPage();
+ const result = await page.evaluate(async ([ua, ub]) => {
+ const load = src => new Promise(resolve => { const image = new Image(); image.onload = () => resolve(image); image.src = src; });
+ const [ia, ib] = await Promise.all([load(ua), load(ub)]);
+ if (ia.width !== ib.width || ia.height !== ib.height) return { size: [ia.width, ia.height, ib.width, ib.height], diffPixels: Infinity };
+ const read = image => { const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; const c = canvas.getContext('2d'); c.drawImage(image, 0, 0); return c.getImageData(0, 0, image.width, image.height).data; };
+ const da = read(ia), db = read(ib); let diffPixels = 0, maxDelta = 0; const box = [Infinity, Infinity, -1, -1];
+ for (let i = 0; i < da.length; i += 4) {
+ const delta = Math.max(Math.abs(da[i] - db[i]), Math.abs(da[i + 1] - db[i + 1]), Math.abs(da[i + 2] - db[i + 2]));
+ if (!delta) continue;
+ diffPixels++; maxDelta = Math.max(maxDelta, delta);
+ const p = i / 4, x = p % ia.width, y = Math.floor(p / ia.width);
+ box[0] = Math.min(box[0], x); box[1] = Math.min(box[1], y); box[2] = Math.max(box[2], x); box[3] = Math.max(box[3], y);
+ }
+ return { size: [ia.width, ia.height], diffPixels, maxDelta, box };
+ }, [`data:image/png;base64,${a.toString('base64')}`, `data:image/png;base64,${b.toString('base64')}`]);
+ await page.close();
+ return result;
+}
+
+const index = await (await fetch(`${target}/index.json`)).json();
+if (!wheelOnly) await check('Storybook OrgChart public menus', () => {
+ const visible = Object.values(index.entries).filter(entry => entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal')).map(entry => `${entry.title} › ${entry.name}`);
+ assert.deepEqual(visible.sort(), ['OrgChart/Department Colors › Department Colors', 'OrgChart/Expanded OrgChart › Expanded OrgChart', 'OrgChart/Full Composition › Full Composition', 'OrgChart/Overview › Overview']);
+ return visible;
+});
+
+for (const id of wheelOnly ? ['orgchart--expanded-org-chart'] : stories) {
+ const page = await open(target, id);
+ const buffer = await page.screenshot({ path: path.join(output, `${id}.png`), fullPage: true });
+ const dom = await snapshot(page);
+ if (baseline) {
+ const reference = await open(baseline, id);
+ const referenceBuffer = await reference.screenshot({ path: path.join(output, `${id}.baseline.png`), fullPage: true });
+ const referenceDom = await snapshot(reference);
+ // Pixel identity is reported, not assumed: when it differs, a second
+ // baseline capture shows whether the baseline itself is deterministic.
+ const pixels = buffer.equals(referenceBuffer) ? { identical: true } : {
+ target: await pixelDiff(buffer, referenceBuffer),
+ baselineSelf: await pixelDiff(referenceBuffer, await (await open(baseline, id)).screenshot({ fullPage: true })),
+ };
+ await check(`${id} matches baseline (DOM + pixels)`, () => {
+ assert.equal(dom, referenceDom, 'shadow DOM differs');
+ // Identical DOM with a handful of ±1 channel pixels is rasterization
+ // jitter of the scaled Fixed stage, not a design change.
+ if (!pixels.identical) assert.ok(pixels.target.diffPixels <= 50 && pixels.target.maxDelta <= 2, `pixel diff ${JSON.stringify(pixels)}`);
+ return { domLength: dom.length, pixels };
+ });
+ await reference.close();
+ }
+ if (id === 'orgchart--expanded-org-chart') {
+ const state = () => page.evaluate(() => {
+ const sr = document.querySelector('.hm-orgchart-host').shadowRoot;
+ const vp = sr.querySelector('.hm-orgchart-viewport');
+ return { scale: +vp.dataset.scale, x: +vp.dataset.panX, y: +vp.dataset.panY,
+ heading: sr.querySelector('.hm-orgchart-title')?.textContent, date: sr.querySelector('.hm-orgchart-date')?.textContent,
+ selection: sr.querySelector('.hm-orgchart-expanded-selection')?.textContent,
+ toolbar: [...sr.querySelectorAll('.hm-orgchart-expanded-toolbar > *')].map(e => e.textContent),
+ feedback: sr.querySelector('.hm-orgchart-move-feedback')?.textContent, presentation: sr.querySelector('.hm-orgchart-shell').dataset.presentationMode ?? null };
+ });
+ const before = await state();
+ if (!wheelOnly) await check('Expanded keeps heading, date, summary, toolbar and guidance', () => {
+ assert.equal(before.presentation, null);
+ assert.ok(before.heading && before.date?.startsWith('기준일') && before.selection?.startsWith('선택'));
+ assert.deepEqual(before.toolbar.slice(0, 4), ['확대', '축소', '100% 복원', 'Fit to View']);
+ assert.match(before.toolbar[4], /^\d+%$/);
+ assert.equal(before.toolbar[5], '드래그·방향키 이동 · Ctrl/⌘ + 휠 확대');
+ assert.match(before.feedback, /Drag & Drop OFF/);
+ return before;
+ });
+ const canvas = page.locator('.hm-orgchart-viewport');
+ await canvas.evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' }));
+ const box = await canvas.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.wheel(0, -100);
+ await page.waitForTimeout(150);
+ const plain = await state();
+ const plainScroll = await page.evaluate(() => scrollY);
+ // A plain wheel scrolls the story page; aim at the canvas again.
+ await canvas.evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' }));
+ const moved = await canvas.boundingBox();
+ await page.mouse.move(moved.x + moved.width / 2, moved.y + moved.height / 2);
+ await page.keyboard.down('Control'); await page.mouse.wheel(0, -100); await page.keyboard.up('Control');
+ await page.waitForTimeout(150);
+ const ctrl = await state();
+ await check('Expanded plain wheel does not zoom; Ctrl + wheel zooms', () => {
+ assert.equal(plain.scale, before.scale);
+ assert.ok(ctrl.scale > plain.scale);
+ return { before: before.scale, plain: plain.scale, ctrl: +ctrl.scale.toFixed(4), pageScrolledByPlainWheel: plainScroll };
+ });
+ if (!wheelOnly) {
+ await page.getByRole('button', { name: 'Fit to View' }).click();
+ const fitted = await state();
+ await check('Expanded Fit to View button still works', () => { assert.ok(fitted.scale <= 1 && fitted.scale !== ctrl.scale); return { scale: +fitted.scale.toFixed(4) }; });
+ }
+ }
+ if (id === 'orgchart--full-composition') {
+ await check('Full Composition keeps Fixed composition', () => page.evaluate(() => {
+ const shells = [...document.querySelectorAll('.hm-orgchart-host')].map(host => host.shadowRoot.querySelector('.hm-orgchart-shell'));
+ if (!shells.length || shells.some(shell => shell.dataset.presentationMode)) throw new Error('unexpected presentation mode');
+ return shells.map(shell => ({ layout: shell.dataset.layoutMode, heading: !!shell.querySelector('.hm-orgchart-heading'), stage: !!shell.querySelector('.hm-orgchart-stage') }));
+ }));
+ }
+ await page.close();
+}
+await browser.close();
+const failed = results.filter(result => !result.pass);
+await writeFile(path.join(output, wheelOnly ? 'results-wheel.json' : 'results.json'), JSON.stringify({ target, baseline, results, consoleErrors }, null, 2));
+console.log(`\n${results.length - failed.length}/${results.length} PASS · console errors ${consoleErrors.length}`);
+for (const error of consoleErrors) console.log('console:', error.origin, error.id, error.text);
+if (failed.length || consoleErrors.length) process.exitCode = 1;
diff --git a/examples/org-chart-chart-only/verify.mjs b/examples/org-chart-chart-only/verify.mjs
new file mode 100644
index 0000000..026d133
--- /dev/null
+++ b/examples/org-chart-chart-only/verify.mjs
@@ -0,0 +1,400 @@
+// Chart Only browser verification against a running preview server:
+// npx vite --config examples/org-chart-chart-only/vite.config.ts
+// node examples/org-chart-chart-only/verify.mjs
+// CHART_ONLY_URL (default http://127.0.0.1:6041) selects the server.
+import assert from 'node:assert/strict';
+import path from 'node:path';
+import { mkdir, writeFile } from 'node:fs/promises';
+import { fileURLToPath } from 'node:url';
+import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
+
+const root = fileURLToPath(new URL('../../', import.meta.url));
+const output = path.join(root, 'test-results/org-chart-chart-only');
+await mkdir(output, { recursive: true });
+const base = process.env.CHART_ONLY_URL ?? 'http://127.0.0.1:6041';
+const browser = await launchChromium();
+const results = [];
+const consoleErrors = [];
+const record = (id, name, pass, detail) => { results.push({ id, name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${name}`, detail === undefined ? '' : JSON.stringify(detail)); };
+const check = (id, name, fn) => async (...args) => {
+ try { const detail = await fn(...args); record(id, name, true, detail); }
+ catch (error) { record(id, name, false, error.message); }
+};
+
+/** All geometry in CSS pixels relative to the canvas; bounds include connectors. */
+const measure = page => page.evaluate(() => {
+ const sr = document.querySelector('.hm-orgchart-host').shadowRoot;
+ const vp = sr.querySelector('.hm-orgchart-viewport'); const box = vp.getBoundingClientRect();
+ let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;
+ for (const e of sr.querySelectorAll('.hm-orgchart-forest .hm-orgchart-organization, .hm-orgchart-forest .hm-orgchart-organization-connectors path')) {
+ const x = e.getBoundingClientRect(); if (!x.width && !x.height) continue;
+ l = Math.min(l, x.left); t = Math.min(t, x.top); r = Math.max(r, x.right); b = Math.max(b, x.bottom);
+ }
+ const css = getComputedStyle;
+ const feedback = sr.querySelector('.hm-orgchart-move-feedback')?.getBoundingClientRect();
+ return {
+ canvas: { left: box.left, top: box.top, width: box.width, height: box.height },
+ scale: +vp.dataset.scale, x: +vp.dataset.panX, y: +vp.dataset.panY, pending: vp.dataset.placementPending ?? null,
+ dragDrop: vp.dataset.dragDropEnabled,
+ margins: { l: l - box.left, t: t - box.top, r: box.right - r, b: box.bottom - b },
+ centerErrX: (l + r) / 2 - (box.left + box.width / 2), centerErrY: (t + b) / 2 - (box.top + box.height / 2),
+ inside: l >= box.left - 0.5 && t >= box.top - 0.5 && r <= box.right + 0.5 && b <= box.bottom + 0.5,
+ cards: sr.querySelectorAll('.hm-orgchart-employee').length,
+ organizations: sr.querySelectorAll('.hm-orgchart-organization').length,
+ connectors: sr.querySelectorAll('.hm-orgchart-forest .edge, .hm-orgchart-organization-connectors path, .hm-orgchart-employee-connectors path').length,
+ external: {
+ heading: !!sr.querySelector('.hm-orgchart-heading'), date: !!sr.querySelector('.hm-orgchart-date'),
+ selection: !!sr.querySelector('.hm-orgchart-expanded-selection'), toolbar: !!sr.querySelector('.hm-orgchart-expanded-toolbar'),
+ zoomRatio: !!sr.querySelector('.hm-orgchart-expanded output'),
+ feedbackVisible: !!feedback && feedback.width > 1 && feedback.height > 1,
+ siblingsOfCanvas: [...sr.querySelector('.hm-orgchart-expanded').children].filter(e => e !== vp && e.getBoundingClientRect().width > 1).map(e => e.className),
+ },
+ colors: { page: css(document.body).backgroundColor, shell: css(sr.querySelector('.hm-orgchart-shell')).backgroundColor, content: css(sr.querySelector('.hm-orgchart-expanded')).backgroundColor, canvas: css(vp).backgroundColor, canvasBorder: css(vp).borderTopWidth, contentPadding: css(sr.querySelector('.hm-orgchart-expanded')).paddingTop },
+ companyColors: [...new Set([...sr.querySelectorAll('.node[data-company-id]')].map(n => `${n.dataset.companyId}:${n.style.getPropertyValue('--hm-orgchart-expanded-primary')}`))],
+ pageOverflowX: document.documentElement.scrollWidth > innerWidth,
+ };
+});
+
+async function ready(page) {
+ await page.waitForFunction(() => {
+ const sr = document.querySelector('.hm-orgchart-host')?.shadowRoot;
+ const vp = sr?.querySelector('.hm-orgchart-viewport');
+ return vp && !vp.dataset.placementPending && sr.querySelector('.hm-orgchart-employee') && sr.querySelector('.hm-orgchart-shell')?.getAttribute('aria-busy') === 'false';
+ }, null, { timeout: 30000 });
+ // Two settled frames: no later Fit may move the view.
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
+}
+
+/** Samples the visible transform from navigation until settle; repeated
+ * visible Fits would show up as more than one distinct transform. */
+async function visibleTransforms(page, ms = 1500) {
+ return page.evaluate(ms => new Promise(resolve => {
+ const seen = []; const start = performance.now();
+ const tick = () => {
+ const vp = document.querySelector('.hm-orgchart-host')?.shadowRoot?.querySelector('.hm-orgchart-viewport');
+ const forest = vp?.querySelector('.hm-orgchart-forest');
+ if (forest && !vp.dataset.placementPending) { const value = forest.style.transform; if (seen.at(-1) !== value) seen.push(value); }
+ if (performance.now() - start < ms) requestAnimationFrame(tick); else resolve(seen);
+ };
+ tick();
+ }), ms);
+}
+
+async function open(context, url) {
+ const page = await context.newPage();
+ page.on('console', message => { if (message.type() === 'error') consoleErrors.push({ url, text: message.text() }); });
+ page.on('pageerror', error => consoleErrors.push({ url, text: String(error) }));
+ await page.goto(url);
+ return page;
+}
+
+const near = (a, b, tolerance) => Math.abs(a - b) <= tolerance;
+const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
+
+// A/B: output and initial Fit for both consumers and all sizes.
+for (const kind of ['vanilla', 'react']) {
+ for (const size of ['20', '100', '500']) {
+ const url = `${base}/${kind}.html?data=${size}`;
+ const page = await open(desktop, url);
+ const transforms = await visibleTransforms(page);
+ await ready(page);
+ const m = await measure(page);
+ await page.screenshot({ path: path.join(output, `${kind}-${size}-desktop.png`) });
+ const label = `${kind} ${size}`;
+ await check('A', `${label} Chart Only output`, () => {
+ assert.deepEqual(Object.entries(m.external).filter(([key, value]) => key === 'siblingsOfCanvas' ? value.length : value), []);
+ assert.equal(m.colors.canvas, 'rgb(255, 255, 255)'); assert.equal(m.colors.content, 'rgb(255, 255, 255)'); assert.equal(m.colors.shell, 'rgb(255, 255, 255)');
+ assert.equal(m.colors.canvasBorder, '0px'); assert.equal(m.colors.contentPadding, '0px');
+ assert.ok(m.cards > 0 && m.organizations > 0 && m.connectors > 0);
+ assert.ok(near(m.canvas.width, 1440, 1) && near(m.canvas.height, 900, 1), 'canvas fills window');
+ return { cards: m.cards, organizations: m.organizations, connectors: m.connectors, colors: m.companyColors };
+ })();
+ await check('B', `${label} initial Fit and center`, () => {
+ assert.ok(m.inside, 'bounds inside canvas');
+ assert.ok(Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, `center error ${m.centerErrX}, ${m.centerErrY}`);
+ assert.ok(m.scale <= 1);
+ assert.ok(Math.min(m.margins.l, m.margins.r, m.margins.t, m.margins.b) >= 15.5, 'edge margin');
+ assert.equal(transforms.length, 1, `visible transforms ${JSON.stringify(transforms)}`);
+ return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3), margins: Object.fromEntries(Object.entries(m.margins).map(([k, v]) => [k, +v.toFixed(2)])), visibleTransforms: transforms.length };
+ })();
+ await page.close();
+ }
+}
+
+// LR / Hybrid keep their layout and still fit.
+for (const layout of ['lr', 'hybrid']) {
+ const page = await open(desktop, `${base}/vanilla.html?data=100&layout=${layout}`);
+ await ready(page);
+ const m = await measure(page);
+ await page.screenshot({ path: path.join(output, `vanilla-100-${layout}.png`) });
+ await check('B', `vanilla 100 ${layout} Fit and center`, () => {
+ assert.ok(m.inside && Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, JSON.stringify(m.margins));
+ return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3) };
+ })();
+ await page.close();
+}
+
+// C/D: wheel, Pan, popup, groups and replacement on the 500-person chart.
+for (const kind of ['vanilla', 'react']) {
+ const page = await open(desktop, `${base}/${kind}.html?data=500`);
+ await ready(page);
+ const fitted = await measure(page);
+ const card = page.locator('.hm-orgchart-employee').nth(30);
+ const cardBox = await card.boundingBox();
+ const point = { x: cardBox.x + cardBox.width / 2, y: cardBox.y + cardBox.height / 2 };
+ const local = m => ({ x: (point.x - m.canvas.left - m.x) / m.scale, y: (point.y - m.canvas.top - m.y) / m.scale });
+ await page.mouse.move(point.x, point.y);
+ await page.mouse.wheel(0, -100);
+ await page.waitForTimeout(50);
+ const zoomedIn = await measure(page);
+ await check('C', `${kind} plain wheel up zooms in (no Ctrl/⌘)`, () => {
+ assert.ok(zoomedIn.scale > fitted.scale * 1.1, `${fitted.scale} -> ${zoomedIn.scale}`);
+ return { from: +fitted.scale.toFixed(4), to: +zoomedIn.scale.toFixed(4) };
+ })();
+ await check('C', `${kind} zoom anchors at pointer`, () => {
+ const before = local(fitted), after = local(zoomedIn);
+ const error = Math.hypot(before.x - after.x, before.y - after.y) * zoomedIn.scale;
+ const box = { x: cardBox.x, y: cardBox.y };
+ assert.ok(error < 0.5, `screen drift ${error}`);
+ return { screenDriftPx: +error.toFixed(4), card: box };
+ })();
+ await page.mouse.wheel(0, 100);
+ await page.waitForTimeout(50);
+ const zoomedOut = await measure(page);
+ await check('C', `${kind} plain wheel down zooms out`, () => {
+ assert.ok(zoomedOut.scale < zoomedIn.scale);
+ return { from: +zoomedIn.scale.toFixed(4), to: +zoomedOut.scale.toFixed(4) };
+ })();
+ for (let i = 0; i < 40; i++) await page.mouse.wheel(0, -120);
+ await page.waitForTimeout(50);
+ const maximum = (await measure(page)).scale;
+ for (let i = 0; i < 80; i++) await page.mouse.wheel(0, 120);
+ await page.waitForTimeout(50);
+ const minimum = (await measure(page)).scale;
+ await check('C', `${kind} min/max limits`, () => {
+ assert.equal(maximum, 3);
+ assert.ok(near(minimum, Math.min(0.05, fitted.scale), 1e-9), `min ${minimum} fit ${fitted.scale}`);
+ return { max: maximum, min: +minimum.toFixed(4), fitScale: +fitted.scale.toFixed(4) };
+ })();
+ // Back to a readable zoom around the same card, then Pan from empty space.
+ await page.mouse.move(point.x, point.y);
+ for (let i = 0; i < 6; i++) await page.mouse.wheel(0, -100);
+ await page.waitForTimeout(50);
+ const beforePan = await measure(page);
+ const empty = { x: beforePan.canvas.left + 6, y: beforePan.canvas.top + beforePan.canvas.height - 6 };
+ const hit = await page.evaluate(({ x, y }) => { const sr = document.querySelector('.hm-orgchart-host').shadowRoot; const e = sr.elementFromPoint(x, y); return e?.closest('.hm-orgchart-employee, button') ? 'control' : e?.className ?? ''; }, empty);
+ await page.mouse.move(empty.x, empty.y); await page.mouse.down();
+ await page.mouse.move(empty.x + 60, empty.y - 40, { steps: 4 }); await page.mouse.move(empty.x + 120, empty.y - 80, { steps: 4 });
+ await page.mouse.up();
+ const afterPan = await measure(page);
+ await check('C', `${kind} Pan after Zoom`, () => {
+ assert.notEqual(hit, 'control');
+ assert.ok(near(afterPan.x - beforePan.x, 120, 0.5) && near(afterPan.y - beforePan.y, -80, 0.5), `${afterPan.x - beforePan.x}, ${afterPan.y - beforePan.y}`);
+ assert.equal(afterPan.scale, beforePan.scale);
+ return { dx: afterPan.x - beforePan.x, dy: afterPan.y - beforePan.y, scale: +afterPan.scale.toFixed(4) };
+ })();
+ await page.waitForTimeout(600);
+ const settled = await measure(page);
+ await check('C', `${kind} no automatic Fit after user Zoom/Pan`, () => {
+ assert.deepEqual([settled.scale, settled.x, settled.y], [afterPan.scale, afterPan.x, afterPan.y]);
+ })();
+ // Ctrl + wheel also zooms in the canvas and the browser page zoom is not used.
+ await page.keyboard.down('Control'); await page.mouse.move(point.x, point.y); await page.mouse.wheel(0, -100); await page.keyboard.up('Control');
+ await page.waitForTimeout(50);
+ const ctrlZoom = await measure(page);
+ const pageZoom = await page.evaluate(() => [visualViewport.scale, innerWidth]);
+ await check('C', `${kind} Ctrl + wheel stays inside the canvas`, () => {
+ assert.ok(ctrlZoom.scale > settled.scale); assert.deepEqual(pageZoom, [1, 1440]);
+ return { scale: +ctrlZoom.scale.toFixed(4) };
+ })();
+
+ // D: employee popup keeps the view.
+ const view = await measure(page);
+ const visibleCard = page.locator('.hm-orgchart-employee').filter({ hasNot: page.locator('[hidden]') }).nth(30);
+ await visibleCard.click();
+ const dialog = page.locator('dialog.hm-orgchart-employee-dialog');
+ const opened = await dialog.evaluate(element => element.open).catch(() => false);
+ const dialogText = opened ? (await dialog.innerText()).replace(/\s+/g, ' ').slice(0, 80) : '';
+ await page.keyboard.press('Escape');
+ await page.waitForTimeout(100);
+ const closed = !(await dialog.evaluate(element => element.open));
+ const afterDialog = await measure(page);
+ await check('D', `${kind} employee popup open/close keeps Zoom/Pan`, () => {
+ assert.ok(opened, 'dialog opened'); assert.ok(closed, 'dialog closed');
+ assert.deepEqual([afterDialog.scale, afterDialog.x, afterDialog.y], [view.scale, view.x, view.y]);
+ return { dialogText };
+ })();
+ // D: group collapse/expand keeps the view and does not Fit.
+ const toggle = page.locator('.hm-orgchart-expanded-toggle[aria-disabled="false"]').nth(3);
+ const id = await toggle.getAttribute('data-organization-id');
+ const cardsBefore = afterDialog.cards;
+ await toggle.click();
+ await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`)?.getAttribute('aria-expanded') === 'false', id);
+ await ready(page);
+ const collapsed = await measure(page);
+ await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`).click();
+ await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`)?.getAttribute('aria-expanded') === 'true', id);
+ await ready(page);
+ const expanded = await measure(page);
+ await check('D', `${kind} group collapse/expand without auto Fit`, () => {
+ assert.ok(collapsed.cards < cardsBefore && expanded.cards === cardsBefore, `${cardsBefore} -> ${collapsed.cards} -> ${expanded.cards}`);
+ assert.deepEqual([collapsed.scale, expanded.scale], [view.scale, view.scale]);
+ assert.deepEqual([expanded.x, expanded.y], [view.x, view.y]);
+ return { cards: [cardsBefore, collapsed.cards, expanded.cards], scale: +view.scale.toFixed(4) };
+ })();
+ // D: Drag & Drop defaults to OFF; a card drag never changes membership.
+ const dragSource = page.locator('.hm-orgchart-employee').nth(40);
+ const sourceId = await dragSource.getAttribute('data-employee-id');
+ const ownerBefore = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"]`)?.closest('.node')?.dataset.organizationId, sourceId);
+ const sourceBox = await dragSource.boundingBox();
+ const targetBox = await page.locator('.hm-orgchart-expanded-toggle').nth(1).boundingBox();
+ await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); await page.mouse.down();
+ await page.mouse.move(targetBox.x + 10, targetBox.y + 5, { steps: 10 }); await page.mouse.up();
+ await page.waitForTimeout(200);
+ const afterDrag = await measure(page);
+ const owner = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"]`)?.closest('.node')?.dataset.organizationId, sourceId);
+ await page.keyboard.press('Escape');
+ await check('D', `${kind} Drag & Drop default OFF`, () => {
+ assert.equal(afterDrag.dragDrop, 'false');
+ assert.ok(owner && owner === ownerBefore, `${ownerBefore} -> ${owner}`);
+ return { dragDropEnabled: afterDrag.dragDrop, owner };
+ })();
+ // B16 / React re-render: user view survives a plain re-render, replacement Fits again.
+ if (kind === 'react') {
+ const userView = await measure(page);
+ await page.evaluate(() => window.rerenderPage());
+ await page.waitForTimeout(400);
+ const rerendered = await measure(page);
+ await check('C', 'react plain re-render keeps user view (no Fit)', () => {
+ assert.deepEqual([rerendered.scale, rerendered.x, rerendered.y], [userView.scale, userView.x, userView.y]);
+ })();
+ }
+ await page.evaluate(() => window.replaceData('100'));
+ await page.waitForFunction(() => document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee').length === 100);
+ await ready(page);
+ const replaced = await measure(page);
+ await page.screenshot({ path: path.join(output, `${kind}-replaced-100.png`) });
+ await check('B', `${kind} full data replacement Fits again`, () => {
+ assert.ok(replaced.inside && Math.abs(replaced.centerErrX) <= 1 && Math.abs(replaced.centerErrY) <= 1, JSON.stringify(replaced.margins));
+ return { scale: +replaced.scale.toFixed(4), centerErrX: +replaced.centerErrX.toFixed(3), centerErrY: +replaced.centerErrY.toFixed(3) };
+ })();
+ await page.close();
+}
+
+// C23: outside the canvas the page scrolls; inside, the wheel zooms.
+for (const kind of ['vanilla', 'react']) {
+ const page = await open(desktop, `${base}/${kind}.html?data=100&embed=1`);
+ await ready(page);
+ await page.mouse.move(700, 300);
+ await page.mouse.wheel(0, 200);
+ await page.waitForTimeout(150);
+ const scrolled = await page.evaluate(() => scrollY);
+ const box = await page.locator('#chart').boundingBox();
+ const before = await measure(page);
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
+ await page.mouse.wheel(0, -100);
+ await page.waitForTimeout(150);
+ const after = await measure(page);
+ const scrollAfter = await page.evaluate(() => scrollY);
+ await check('C', `${kind} page scroll outside canvas / zoom inside`, () => {
+ assert.ok(scrolled >= 150, `outside scrollY ${scrolled}`);
+ assert.equal(scrollAfter, scrolled, 'inside wheel does not scroll the page');
+ assert.ok(after.scale > before.scale);
+ return { outsideScrollY: scrolled, insideScrollY: scrollAfter, scale: [+before.scale.toFixed(4), +after.scale.toFixed(4)] };
+ })();
+ await page.close();
+}
+
+// D28: optional HM Dialog adapter.
+{
+ const page = await open(desktop, `${base}/react-hm-dialog.html?data=20`);
+ await ready(page);
+ const view = await measure(page);
+ await page.locator('.hm-orgchart-employee').nth(4).click();
+ const hmDialog = page.locator('[data-slot="dialog-content"]');
+ await hmDialog.waitFor({ timeout: 5000 });
+ await page.waitForTimeout(400);
+ await page.screenshot({ path: path.join(output, 'react-hm-dialog-open.png') });
+ const text = (await hmDialog.innerText()).replace(/\s+/g, ' ').slice(0, 80);
+ const coreDialogOpen = await page.locator('dialog.hm-orgchart-employee-dialog').evaluate(element => element.open);
+ await page.keyboard.press('Escape');
+ await hmDialog.waitFor({ state: 'detached', timeout: 5000 });
+ const after = await measure(page);
+ await check('D', 'react /hm-dialog Chart Only uses HM Dialog', () => {
+ assert.equal(coreDialogOpen, false);
+ assert.deepEqual([after.scale, after.x, after.y], [view.scale, view.x, view.y]);
+ return { text };
+ })();
+ await page.close();
+}
+// Preserve the untouched initial Fit through controller recreation. The manual
+// minimum is deliberately above Fit to exercise the saved floor without 2,100+ data.
+for (const kind of ['vanilla', 'react']) {
+ const page = await open(desktop, `${base}/${kind}.html?data=500&verify-min=1`);
+ await ready(page);
+ const initial = await measure(page);
+ const title = page.locator('.hm-orgchart-expanded-toggle').nth(1);
+ await title.click();
+ await page.waitForTimeout(250);
+ const collapsed = await measure(page);
+ await check('R1', `${kind} untouched Fit survives collapse`, () => {
+ assert.ok(initial.scale < 0.8);
+ assert.deepEqual([collapsed.scale, collapsed.x, collapsed.y], [initial.scale, initial.x, initial.y]);
+ })();
+ await title.click(); await page.waitForTimeout(250);
+ const expanded = await measure(page);
+ const viewport = page.locator('.hm-orgchart-viewport'); const box = await viewport.boundingBox();
+ await page.mouse.move(box.x + 8, box.y + box.height - 8); await page.mouse.wheel(0, 120);
+ await page.waitForTimeout(150);
+ const zoomed = await measure(page);
+ await check('R2', `${kind} recreated controller retains below-min Fit floor`, () => {
+ assert.deepEqual([expanded.scale, expanded.x, expanded.y], [initial.scale, initial.x, initial.y]);
+ assert.equal(zoomed.scale, initial.scale, 'wheel out must not jump to 0.8');
+ })();
+ if (kind === 'vanilla') {
+ await page.evaluate(() => window.orgChart.setInteractions({ zoom: false }));
+ await page.evaluate(() => window.orgChart.setInteractions({ zoom: true }));
+ const changed = await measure(page);
+ await check('R3', 'Vanilla interaction recreation retains initial view', () => {
+ assert.deepEqual([changed.scale, changed.x, changed.y], [initial.scale, initial.x, initial.y]);
+ })();
+ }
+ await page.close();
+}
+await desktop.close();
+
+// Mobile: initial Fit, touch Pan and layout only (no wheel claims).
+const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
+for (const kind of ['vanilla', 'react']) {
+ for (const size of ['20', '100', '500']) {
+ const page = await open(mobile, `${base}/${kind}.html?data=${size}`);
+ await ready(page);
+ const m = await measure(page);
+ if (size !== '100') await page.screenshot({ path: path.join(output, `${kind}-${size}-mobile.png`) });
+ let touch;
+ if (size === '20') {
+ const cdp = await mobile.newCDPSession(page);
+ const start = { x: m.canvas.left + 8, y: m.canvas.top + m.canvas.height - 8 };
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [start] });
+ for (let i = 1; i <= 5; i++) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start.x + i * 12, y: start.y - i * 10 }] });
+ await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
+ const moved = await measure(page);
+ touch = { dx: moved.x - m.x, dy: moved.y - m.y };
+ }
+ await check('B', `${kind} ${size} mobile Fit${touch ? ' + touch Pan' : ''}`, () => {
+ assert.ok(m.inside && Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, JSON.stringify(m.margins));
+ assert.equal(m.pageOverflowX, false);
+ if (touch) assert.ok(Math.abs(touch.dx) > 20 && Math.abs(touch.dy) > 20, JSON.stringify(touch));
+ return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3), touch };
+ })();
+ await page.close();
+ }
+}
+await mobile.close();
+await browser.close();
+
+const failed = results.filter(result => !result.pass);
+await writeFile(path.join(output, 'results.json'), JSON.stringify({ base, results, consoleErrors }, null, 2));
+console.log(`\n${results.length - failed.length}/${results.length} PASS · console errors ${consoleErrors.length}`);
+for (const error of consoleErrors) console.log('console:', error.url, error.text);
+if (failed.length || consoleErrors.length) process.exitCode = 1;
diff --git a/examples/org-chart-chart-only/vite.config.ts b/examples/org-chart-chart-only/vite.config.ts
new file mode 100644
index 0000000..faa5213
--- /dev/null
+++ b/examples/org-chart-chart-only/vite.config.ts
@@ -0,0 +1,18 @@
+import path from 'node:path';
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
+
+// Chart Only consumer preview: Vanilla and React use the same public Core.
+const root = path.resolve(import.meta.dirname);
+export default defineConfig({
+ root,
+ base: './',
+ publicDir: path.resolve(root, '../../public'),
+ plugins: [react(), tailwindcss()],
+ server: { host: '127.0.0.1', port: 6041 },
+ build: {
+ outDir: 'dist', emptyOutDir: true,
+ rolldownOptions: { input: { vanilla: path.resolve(root, 'vanilla.html'), react: path.resolve(root, 'react.html'), 'react-hm-dialog': path.resolve(root, 'react-hm-dialog.html') } },
+ },
+});
diff --git a/packages/org-chart-react/src/OrgChart.tsx b/packages/org-chart-react/src/OrgChart.tsx
index db00077..5abde22 100644
--- a/packages/org-chart-react/src/OrgChart.tsx
+++ b/packages/org-chart-react/src/OrgChart.tsx
@@ -21,12 +21,17 @@ function annotateEmployeeCards(shadow: ShadowRoot, popup: NonNullable JSON.stringify(value ?? null);
+
interface Instance {
readonly handle: OrgChartHandle;
readonly generation: number;
source: OrgChartDataSource;
autoLayout: OrgChartAutoLayoutOptions | undefined;
interactions: OrgChartInteractionOptions | undefined;
+ selectedCompanyIds: readonly string[] | undefined;
dragDropEnabled: boolean;
readonly employeeClick: NonNullable;
employeeCallbackEnabled: boolean;
@@ -49,6 +54,9 @@ export function OrgChart({
companyTopNodes,
employeeConnections,
zoom,
+ presentationMode,
+ initialView,
+ wheelZoom,
organizationLinks,
onEmployeeClick,
onMove,
@@ -126,6 +134,9 @@ export function OrgChart({
companyTopNodes,
employeeConnections,
zoom,
+ presentationMode,
+ initialView,
+ wheelZoom,
organizationLinks,
onEmployeeClick: hasAdapterCallback ? employeeClick : undefined,
onMove: result => {
@@ -134,7 +145,7 @@ export function OrgChart({
committedRef.current.onMove?.(result);
},
});
- const instance: Instance = { handle, source, autoLayout, interactions, dragDropEnabled, employeeClick, employeeCallbackEnabled: hasAdapterCallback, popupMode, generation, operation: 1 };
+ const instance: Instance = { handle, source, autoLayout, interactions, selectedCompanyIds, dragDropEnabled, employeeClick, employeeCallbackEnabled: hasAdapterCallback, popupMode, generation, operation: 1 };
instanceRef.current = instance;
unsubscribe = handle.subscribe(state => {
if (instanceRef.current === instance && generationRef.current === generation) {
@@ -181,7 +192,7 @@ export function OrgChart({
}
}
};
- }, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, layoutMode, companyId, companyTopNodes, employeeConnections, zoom, organizationLinks, fixedHasEmployeeCallback]);
+ }, [optionKey(aggregationPolicy), engineStylesheetUrl, coreStylesheetUrl, displayMode, layoutMode, companyId, optionKey(companyTopNodes), optionKey(employeeConnections), optionKey(zoom), presentationMode, initialView, wheelZoom, optionKey(organizationLinks), fixedHasEmployeeCallback]);
useLayoutEffect(() => {
const instance = instanceRef.current;
@@ -208,14 +219,14 @@ export function OrgChart({
useLayoutEffect(() => {
const instance = instanceRef.current;
- if (!instance || layoutMode !== 'auto' || instance.autoLayout === autoLayout) return;
+ if (!instance || layoutMode !== 'auto' || optionKey(instance.autoLayout) === optionKey(autoLayout)) return;
instance.autoLayout = autoLayout;
void instance.handle.setAutoLayout(autoLayout ?? { type: 'tb', hybridStartDepth: 3, initialVisibleDepth: 'all', hybridOrganizationIds: [] }).catch(error => {
if (instanceRef.current !== instance) return;
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Keep layout setter failures in the lifecycle error contract. */ }
});
- }, [autoLayout, layoutMode]);
+ }, [optionKey(autoLayout), layoutMode]);
useLayoutEffect(() => {
const instance = instanceRef.current;
@@ -231,24 +242,26 @@ export function OrgChart({
useLayoutEffect(() => {
const instance = instanceRef.current;
- if (!instance || layoutMode !== 'auto' || instance.interactions === interactions) return;
+ if (!instance || layoutMode !== 'auto' || optionKey(instance.interactions) === optionKey(interactions)) return;
instance.interactions = interactions;
void instance.handle.setInteractions({ collapse: true, zoom: true, pan: true, ...interactions }).catch(error => {
if (instanceRef.current !== instance) return;
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Interaction controls use the existing lifecycle error contract. */ }
});
- }, [interactions, layoutMode]);
+ }, [optionKey(interactions), layoutMode]);
useLayoutEffect(() => {
const instance = instanceRef.current;
- if (!instance || layoutMode !== 'auto') return;
+ // Mount already passed this selection to Core; only a changed value re-applies.
+ if (!instance || layoutMode !== 'auto' || optionKey(instance.selectedCompanyIds) === optionKey(selectedCompanyIds)) return;
+ instance.selectedCompanyIds = selectedCompanyIds;
void instance.handle.setSelectedCompanies(selectedCompanyIds).catch(error => {
if (instanceRef.current !== instance) return;
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Match other lifecycle callbacks: never leak a rejected Promise. */ }
});
- }, [selectedCompanyIds, layoutMode]);
+ }, [optionKey(selectedCompanyIds), layoutMode]);
useLayoutEffect(() => {
const instance = instanceRef.current;
diff --git a/packages/org-chart/src/core/department-colors.ts b/packages/org-chart/src/core/department-colors.ts
index 80c9949..1ea895c 100644
--- a/packages/org-chart/src/core/department-colors.ts
+++ b/packages/org-chart/src/core/department-colors.ts
@@ -72,10 +72,10 @@ export function createDepartmentColors(document: Document, context: RenderContex
const comparisonTitle = document.createElement('h3'); comparisonTitle.textContent = '텍스트 대비 MODEL · 현재 / 일반 제목 / 미적용 후보';
const normal = document.createElement('div'); normal.className = 'hm-orgchart-contrast-normal'; normal.dataset.contrastTarget = 'full-normal'; normal.textContent = preset.label;
const candidate = document.createElement('div'); candidate.className = 'hm-orgchart-contrast-candidate'; candidate.dataset.contrastTarget = 'candidate'; candidate.dataset.designStatus = 'UNAPPROVED_NOT_APPLIED'; candidate.textContent = preset.label;
- candidate.style.color = 'var(--hm-orgchart-contrast-candidate-color, #10243b)';
+ candidate.style.color = '#10243b';
const chip = document.createElement('div'); chip.className = 'hm-orgchart-contrast-chip'; chip.dataset.designStatus = 'UNAPPROVED_NOT_APPLIED';
const chipLabel = document.createElement('span'); chipLabel.className = 'hm-orgchart-contrast-chip-label'; chipLabel.dataset.contrastTarget = 'candidate-chip'; chipLabel.textContent = preset.label;
- chipLabel.style.color = 'var(--hm-orgchart-contrast-candidate-color, #10243b)'; chipLabel.style.backgroundColor = 'var(--hm-orgchart-contrast-candidate-chip-background, #ffffff)'; chip.append(chipLabel);
+ chipLabel.style.color = '#10243b'; chipLabel.style.backgroundColor = '#ffffff'; chip.append(chipLabel);
const results = document.createElement('dl'); results.className = 'hm-orgchart-contrast-results';
for (const [key, label] of [['current', '현재 Story 제목: 실제 크기 기준'], ['full-normal', 'Full 제목 동등 예제: 15px bold 일반 텍스트'], ['candidate', '후보 #10243b: 미승인·미적용'], ['candidate-chip', '후보 white label + #10243b: 미승인·미적용']]) {
const term = document.createElement('dt'); term.textContent = label;
diff --git a/packages/org-chart/src/core/expanded-content.ts b/packages/org-chart/src/core/expanded-content.ts
index 2a79785..358fa05 100644
--- a/packages/org-chart/src/core/expanded-content.ts
+++ b/packages/org-chart/src/core/expanded-content.ts
@@ -3,6 +3,7 @@ import { createHeading, type RenderContext } from './render.js';
import type { RenderEngineNode } from './engine.js';
import { getCompanyColorPreset } from '../company-color-presets.js';
import { alignExpandedLevels } from './expanded-layout.js';
+import type { OrgChartWheelZoom } from './types.js';
/** Auto-only presenter. The source and popup identity remain unchanged. */
export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode, context: RenderContext, signal: AbortSignal): void {
@@ -27,7 +28,6 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
node.dataset.verticalLayout = String(vertical);
const layoutDirection = context.autoLayout?.type === 'lr' || vertical ? 'left' : 'top';
node.style.setProperty('--hm-orgchart-expanded-primary', preset.primary);
- node.style.setProperty('--hm-orgchart-expanded-title-ink', ['saman', 'jangheon-industry'].includes(companyId) ? 'var(--hm-orgchart-resolved-fg-primary)' : 'var(--hm-orgchart-resolved-on-action-primary)');
preset.secondary.forEach((color, index) => node.style.setProperty(`--hm-orgchart-expanded-secondary-${index + 1}`, color));
for (const edge of node.querySelectorAll('.edge')) edge.setAttribute('aria-hidden', 'true');
const leaderId = top?.employeeId === undefined ? organization.leaderId : top.employeeId;
@@ -90,11 +90,13 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
}
}
-export function createExpandedOrgChartContent(document: Document, context: RenderContext, tree: HTMLElement, signal: AbortSignal, layoutReady: () => void): HTMLElement {
+export function createExpandedOrgChartContent(document: Document, context: RenderContext, tree: HTMLElement, signal: AbortSignal, layoutReady: () => void,
+ presentation: { chartOnly?: boolean; wheelZoom?: OrgChartWheelZoom } = {}): HTMLElement {
const content = document.createElement('div');
content.className = 'hm-orgchart-expanded';
content.dataset.component = 'ExpandedOrgChartContent';
content.dataset.layoutMode = 'auto';
+ if (presentation.chartOnly) content.dataset.presentationMode = 'chart-only';
content.dataset.autoLayout = context.autoLayout?.type ?? 'tb';
content.dataset.hybridStartDepth = String(context.autoLayout?.hybridStartDepth ?? 3);
content.dataset.initialVisibleDepth = String(context.autoLayout?.initialVisibleDepth ?? 'all');
@@ -105,8 +107,10 @@ export function createExpandedOrgChartContent(document: Document, context: Rende
const preset = getCompanyColorPreset(company);
if (preset) content.style.setProperty(`--hm-orgchart-company-${company}`, preset.primary);
}
- tree.setAttribute('aria-label', '확장형 조직 계층도 · Ctrl 또는 Command + 휠로 확대 · 드래그로 이동');
- content.append(createHeading(document, context), tree);
+ tree.setAttribute('aria-label', `확장형 조직 계층도 · ${presentation.wheelZoom === 'direct' ? '휠로 확대·축소' : 'Ctrl 또는 Command + 휠로 확대'} · 드래그로 이동`);
+ // Chart Only: titles inside organization nodes remain; the outer heading does not.
+ if (presentation.chartOnly) content.append(tree);
+ else content.append(createHeading(document, context), tree);
alignExpandedLevels(tree, signal, layoutReady, context.autoLayout);
return content;
}
diff --git a/packages/org-chart/src/core/expanded-viewport.ts b/packages/org-chart/src/core/expanded-viewport.ts
index c4b6e5b..98a2ea0 100644
--- a/packages/org-chart/src/core/expanded-viewport.ts
+++ b/packages/org-chart/src/core/expanded-viewport.ts
@@ -1,4 +1,4 @@
-import type { OrgChartViewportState } from './types.js';
+import type { OrgChartViewportState, OrgChartWheelZoom } from './types.js';
export interface ExpandedViewport {
zoomTo(scale: number, anchor?: { x: number; y: number }): void;
@@ -6,18 +6,27 @@ export interface ExpandedViewport {
fit(): void;
state(): OrgChartViewportState;
hasManualPosition(): boolean;
+ fitScale(): number;
layoutReady(): void;
}
/** One transformed forest, shared by HTML and React; dialogs live in the shell. */
-export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLElement, signal: AbortSignal,
- config: { min: number; max: number; step: number; zoomEnabled?: boolean; panEnabled?: boolean }, initial?: OrgChartViewportState, centerInitially = true, initialManualPosition = false): ExpandedViewport {
+/** `toolbar` is optional: Chart Only keeps every viewport interaction without
+ * rendering its buttons, ratio text or guidance. */
+export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLElement | undefined, signal: AbortSignal,
+ config: { min: number; max: number; step: number; zoomEnabled?: boolean; panEnabled?: boolean; wheelZoom?: OrgChartWheelZoom; fitFloor?: boolean; initialFitScale?: number },
+ initial?: OrgChartViewportState, initialPlacement?: 'center' | 'fit', initialManualPosition = false): ExpandedViewport {
const document = viewport.ownerDocument;
const view = document.defaultView!;
const forest = viewport.querySelector('.hm-orgchart-forest')!;
let { scale, x, y } = initial ?? { scale: 1, x: 0, y: 0 };
let manualPosition = initialManualPosition;
- let placement: 'center' | 'fit' | undefined = centerInitially && !manualPosition ? 'center' : undefined;
+ let placement: 'center' | 'fit' | undefined = initialPlacement && !manualPosition ? initialPlacement : undefined;
+ // An initial Fit hides the forest until real bounds and canvas size are
+ // measured, so the first frame never shows an unfitted 100% tree.
+ if (placement === 'fit') viewport.dataset.placementPending = 'true';
+ else delete viewport.dataset.placementPending;
+ let fitScale = config.initialFitScale ?? Infinity;
let drag: { id: number; x: number; y: number; moved: boolean } | undefined;
let suppressClick = false;
let focusFrame = 0;
@@ -34,10 +43,14 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
viewport.dataset.panX = String(x);
viewport.dataset.panY = String(y);
output.textContent = `${Math.round(scale * 100)}%`;
- buttons[0].disabled = !zoomEnabled || scale >= config.max;
- buttons[1].disabled = !zoomEnabled || scale <= config.min;
+ if (buttons.length) {
+ buttons[0].disabled = !zoomEnabled || scale >= config.max;
+ buttons[1].disabled = !zoomEnabled || scale <= config.min;
+ }
};
- const clamp = (value: number) => Math.max(config.min, Math.min(config.max, value));
+ // With an initial Fit, a large forest can fit below the manual minimum.
+ // Zooming out from there holds the fitted scale instead of jumping inward.
+ const clamp = (value: number) => Math.max(config.fitFloor ? Math.min(config.min, fitScale) : config.min, Math.min(config.max, value));
// Engine wrappers reserve asymmetric margins (LR) and empty row space (TB).
// Center the complete rendered content, including organization connector rails,
// in forest-local coordinates so Pan/Zoom do not affect the measured bounds.
@@ -60,12 +73,14 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
if (fit) {
// Only an explicit Fit shrinks to contain, even below manual Zoom's min.
scale = Math.max(Number.EPSILON, Math.min((viewport.clientWidth - 32) / bounds.width, (viewport.clientHeight - 32) / bounds.height, 1));
+ fitScale = scale;
}
x = (viewport.clientWidth - bounds.width * scale) / 2 - bounds.left * scale;
y = (fit ? Math.max(16, (viewport.clientHeight - bounds.height * scale) / 2) : 16) - bounds.top * scale;
apply();
+ delete viewport.dataset.placementPending;
};
- const preservePosition = () => { manualPosition = true; placement = undefined; };
+ const preservePosition = () => { manualPosition = true; placement = undefined; delete viewport.dataset.placementPending; };
const controller: ExpandedViewport = {
zoomTo(next, anchor = { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 }) {
if (!zoomEnabled) return;
@@ -82,6 +97,7 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
},
state: () => ({ scale, x, y }),
hasManualPosition: () => manualPosition,
+ fitScale: () => fitScale,
layoutReady() { if (placement) position(placement === 'fit'); },
};
const actions: [string, () => void][] = [
@@ -90,24 +106,34 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
['100% 복원', () => { preservePosition(); scale = 1; x = 16; y = 16; apply(); }],
['Fit to View', () => controller.fit()],
];
- toolbar.setAttribute('role', 'group');
- toolbar.setAttribute('aria-label', '조직도 확대·이동 도구');
- for (const [label, action] of actions) {
- const button = document.createElement('button');
- button.type = 'button'; button.textContent = label;
- button.addEventListener('click', action, { signal });
- buttons.push(button); toolbar.append(button);
+ const directWheel = config.wheelZoom === 'direct';
+ if (toolbar) {
+ toolbar.setAttribute('role', 'group');
+ toolbar.setAttribute('aria-label', '조직도 확대·이동 도구');
+ for (const [label, action] of actions) {
+ const button = document.createElement('button');
+ button.type = 'button'; button.textContent = label;
+ button.addEventListener('click', action, { signal });
+ buttons.push(button); toolbar.append(button);
+ }
+ toolbar.append(output);
+ const hint = document.createElement('span');
+ hint.textContent = [panEnabled ? '드래그·방향키 이동' : '', zoomEnabled ? directWheel ? '휠 확대·축소' : 'Ctrl/⌘ + 휠 확대' : ''].filter(Boolean).join(' · ');
+ toolbar.append(hint);
}
- toolbar.append(output);
- const hint = document.createElement('span');
- hint.textContent = [panEnabled ? '드래그·방향키 이동' : '', zoomEnabled ? 'Ctrl/⌘ + 휠 확대' : ''].filter(Boolean).join(' · ');
- toolbar.append(hint);
+ // The listener lives on the canvas only: outside it, the page scrolls as usual.
viewport.addEventListener('wheel', event => {
if (!zoomEnabled) return;
- if (!event.ctrlKey && !event.metaKey) return;
+ if (!directWheel && !event.ctrlKey && !event.metaKey) return;
+ // Direct mode normalizes line/page deltas and caps one event so a
+ // high-resolution wheel cannot jump across the zoom range.
+ const delta = directWheel
+ ? Math.max(-120, Math.min(120, event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1)))
+ : event.deltaY;
+ if (directWheel && !delta) return;
event.preventDefault();
const box = viewport.getBoundingClientRect();
- controller.zoomTo(scale * Math.exp(-event.deltaY * 0.002), { x: event.clientX - box.left, y: event.clientY - box.top });
+ controller.zoomTo(scale * Math.exp(-delta * 0.002), { x: event.clientX - box.left, y: event.clientY - box.top });
}, { passive: false, signal });
viewport.addEventListener('keydown', event => {
if (event.target !== viewport) return;
diff --git a/packages/org-chart/src/core/index.ts b/packages/org-chart/src/core/index.ts
index b8236fd..bb65fda 100644
--- a/packages/org-chart/src/core/index.ts
+++ b/packages/org-chart/src/core/index.ts
@@ -70,6 +70,14 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
if (Object.values(employeeConnections).some(value => !['top', 'left', 'right'].includes(value))) throw new TypeError('employeeConnections must use top, left or right');
const zoom = { min: options.zoom?.min ?? 0.05, max: options.zoom?.max ?? 3, step: options.zoom?.step ?? 1.2 };
if (expanded && (!Number.isFinite(zoom.min) || !Number.isFinite(zoom.max) || !Number.isFinite(zoom.step) || zoom.min <= 0 || zoom.min > 1 || zoom.max < 1 || zoom.min >= zoom.max || zoom.step <= 1)) throw new TypeError('zoom requires 0 < min <= 1 <= max and step > 1');
+ if (options.presentationMode !== undefined && options.presentationMode !== 'default' && options.presentationMode !== 'chart-only') throw new TypeError('presentationMode must be "default" or "chart-only"');
+ if (options.initialView !== undefined && options.initialView !== 'center' && options.initialView !== 'fit') throw new TypeError('initialView must be "center" or "fit"');
+ if (options.wheelZoom !== undefined && options.wheelZoom !== 'modifier' && options.wheelZoom !== 'direct') throw new TypeError('wheelZoom must be "modifier" or "direct"');
+ // Fixed owns the approved Workdesk composition; never strip it silently.
+ if (view === 'composition' && options.presentationMode === 'chart-only' && !expanded) throw new TypeError('presentationMode "chart-only" requires layoutMode "auto"');
+ const chartOnly = expanded && options.presentationMode === 'chart-only';
+ const initialView = options.initialView ?? (chartOnly ? 'fit' : 'center');
+ const wheelZoom = options.wheelZoom ?? (chartOnly ? 'direct' : 'modifier');
const copySelection = (ids?: readonly string[]) => {
if (ids !== undefined && (!Array.isArray(ids) || ids.some(id => typeof id !== 'string' || !getCompanyColorPreset(id)))) throw new TypeError('selectedCompanyIds must contain registered company IDs');
return ids === undefined ? undefined : [...new Set(ids)];
@@ -327,6 +335,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
if (LoadedEngine) Engine = LoadedEngine;
const document = content!.ownerDocument;
const previousViewport = expandedViewport?.state();
+ const previousFitScale = expandedViewport?.fitScale();
const previousManualPosition = expandedViewport?.hasManualPosition();
employeeDialog?.close();
contentGeneration?.abort();
@@ -354,18 +363,24 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
}
// If regeneration removed keyboard focus, offer the stable viewport next.
const nextContent = view === 'composition' ? expanded
- ? createExpandedOrgChartContent(document, nextContext, viewport!, contentGeneration.signal, () => expandedViewport?.layoutReady())
+ ? createExpandedOrgChartContent(document, nextContext, viewport!, contentGeneration.signal, () => expandedViewport?.layoutReady(), { chartOnly, wheelZoom })
: createOrgChartContent(document, nextContext, viewport!, contentGeneration.signal)
: view === 'tree' ? viewport!
: view === 'positionSummary' ? createPositionSummary(document, nextContext)
: view === 'affiliationSummary' ? createAffiliationSummary(document, nextContext)
: createStatusSummary(document, nextContext, contentGeneration.signal);
if (expanded) {
- const toolbar = document.createElement('div');
- toolbar.className = 'hm-orgchart-expanded-toolbar';
- nextContent.insertBefore(toolbar, viewport!);
+ // Chart Only keeps the same viewport controller without its visible toolbar.
+ const toolbar = chartOnly ? undefined : document.createElement('div');
+ if (toolbar) {
+ toolbar.className = 'hm-orgchart-expanded-toolbar';
+ nextContent.insertBefore(toolbar, viewport!);
+ }
+ // A fitted replacement starts from the new forest, not the previous Pan/Zoom.
+ const resetView = fitNext && initialView === 'fit';
expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal,
- { ...zoom, zoomEnabled: interactionOptions.zoom, panEnabled: interactionOptions.pan }, previousViewport, fitNext, previousManualPosition);
+ { ...zoom, zoomEnabled: interactionOptions.zoom, panEnabled: interactionOptions.pan, wheelZoom, fitFloor: initialView === 'fit', initialFitScale: resetView ? undefined : previousFitScale },
+ resetView ? undefined : previousViewport, fitNext ? initialView : undefined, !resetView && !!previousManualPosition);
fitNext = false;
const feedback = document.createElement('p');
feedback.className = 'hm-orgchart-move-feedback'; feedback.id = `${prefix}-move-feedback`;
@@ -383,13 +398,15 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
else source.removeAttribute('aria-keyshortcuts');
source.setAttribute('aria-describedby', feedback.id);
}
- const selection = document.createElement('p');
- selection.className = 'hm-orgchart-expanded-selection';
- selection.setAttribute('aria-live', 'polite');
- const selected = new Set(roots.map(root => companyState!.companies.get(root.organization.id)!));
- const employeeCount = data.employees.filter(employee => selected.has(companyState!.companies.get(employee.organizationId)!)).length;
- selection.textContent = `선택 ${selected.size}개 회사 · ${employeeCount}명 / 원본 ${data.employees.length}명`;
- nextContent.insertBefore(selection, toolbar);
+ if (toolbar) {
+ const selection = document.createElement('p');
+ selection.className = 'hm-orgchart-expanded-selection';
+ selection.setAttribute('aria-live', 'polite');
+ const selected = new Set(roots.map(root => companyState!.companies.get(root.organization.id)!));
+ const employeeCount = data.employees.filter(employee => selected.has(companyState!.companies.get(employee.organizationId)!)).length;
+ selection.textContent = `선택 ${selected.size}개 회사 · ${employeeCount}명 / 원본 ${data.employees.length}명`;
+ nextContent.insertBefore(selection, toolbar);
+ }
if (!roots.length) {
const empty = document.createElement('p');
empty.className = 'hm-orgchart-expanded-empty';
@@ -447,6 +464,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
root = document.createElement('div');
root.className = 'hm-orgchart-host';
root.dataset.orgchartInstance = prefix;
+ if (chartOnly) root.dataset.presentationMode = 'chart-only';
const shadow = root.attachShadow({ mode: 'open' });
const styleDisposers: (() => void)[] = [];
const styles = [...(needsEngine ? [{ key: 'engine', href: stylesheetUrls.engine }] : []), { key: 'core', href: stylesheetUrls.core }];
@@ -472,6 +490,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
shell = document.createElement('section');
shell.className = 'hm-orgchart-shell';
shell.dataset.layoutMode = view === 'composition' ? layoutMode : 'fixed';
+ if (chartOnly) shell.dataset.presentationMode = 'chart-only';
// The Core's Korean labels must shape identically in ko HTML and en Storybook hosts.
shell.lang = 'ko';
shell.tabIndex = -1;
diff --git a/packages/org-chart/src/core/types.ts b/packages/org-chart/src/core/types.ts
index ab216b8..5d49d0d 100644
--- a/packages/org-chart/src/core/types.ts
+++ b/packages/org-chart/src/core/types.ts
@@ -50,6 +50,17 @@ export interface OrgChartTopNode {
}
export interface OrgChartViewportState { scale: number; x: number; y: number }
+/** Auto only. 'chart-only' renders a white canvas with nodes and connectors:
+ * no heading, as-of date, company/count summary, toolbar or visible guidance.
+ * Accessible live status stays visually hidden. Fixed rejects it. */
+export type OrgChartPresentationMode = 'default' | 'chart-only';
+/** Auto only. Placement on first render and on full data/layout replacement.
+ * 'fit' contains the whole forest (never above 100%) and centers it. */
+export type OrgChartInitialView = 'center' | 'fit';
+/** Auto only. 'modifier' zooms with Ctrl/⌘ + wheel; 'direct' zooms with the
+ * plain wheel inside the canvas. Both anchor at the pointer. */
+export type OrgChartWheelZoom = 'modifier' | 'direct';
+
export interface OrgChartEmployeeClickContext {
readonly organization: Organization | undefined;
readonly affiliation: Affiliation | undefined;
@@ -84,6 +95,12 @@ export interface OrgChartOptions {
* from above. LR always routes employees from the left. */
employeeConnections?: Readonly>;
zoom?: { min?: number; max?: number; step?: number };
+ /** Auto only. Defaults to 'default' (existing heading, summary and toolbar). */
+ presentationMode?: OrgChartPresentationMode;
+ /** Auto only. Defaults to 'fit' in chart-only, otherwise 'center'. */
+ initialView?: OrgChartInitialView;
+ /** Auto only. Defaults to 'direct' in chart-only, otherwise 'modifier'. */
+ wheelZoom?: OrgChartWheelZoom;
/** No entry (or an empty URL) keeps an ordinary title. HTTP(S) and relative URLs are supported. */
organizationLinks?: OrgChartOrganizationLinks;
/** Optional host-owned information UI. When supplied, it replaces the default read-only dialog.
diff --git a/packages/org-chart/src/index.ts b/packages/org-chart/src/index.ts
index c731930..4350dae 100644
--- a/packages/org-chart/src/index.ts
+++ b/packages/org-chart/src/index.ts
@@ -2,6 +2,6 @@ export * from './data/index.js';
export { companyColorPresets, getCompanyColorPreset } from './company-color-presets.js';
export type { CompanyColorPreset, CompanyId } from './company-color-presets.js';
export { createOrgChart } from './core/index.js';
-export type { OrgChartInteractionOptions, OrgChartAutoLayoutType, OrgChartAutoLayoutOptions, OrgChartConnectionDirection, OrgChartTopNode, OrgChartViewportState, OrgChartDisplayMode, OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './core/types.js';
+export type { OrgChartInteractionOptions, OrgChartPresentationMode, OrgChartInitialView, OrgChartWheelZoom, OrgChartAutoLayoutType, OrgChartAutoLayoutOptions, OrgChartConnectionDirection, OrgChartTopNode, OrgChartViewportState, OrgChartDisplayMode, OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './core/types.js';
export { createOrgChartTree, createPositionSummary, createAffiliationSummary, createStatusSummary } from './components.js';
export type { OrgChartSummaryComponentOptions } from './components.js';
diff --git a/packages/org-chart/src/styles/org-chart-expanded.css b/packages/org-chart/src/styles/org-chart-expanded.css
index 8c2fef3..62f3548 100644
--- a/packages/org-chart/src/styles/org-chart-expanded.css
+++ b/packages/org-chart/src/styles/org-chart-expanded.css
@@ -273,3 +273,24 @@
.hm-orgchart-expanded .node[data-drop-state="blocked"] > .hm-orgchart-organization { outline: 1px dotted var(--hm-orgchart-resolved-stroke-default); outline-offset: 2px; }
.hm-orgchart-expanded .node[data-drop-hover="allowed"] > .hm-orgchart-organization { outline: 3px solid var(--hm-orgchart-resolved-action-primary); }
.hm-orgchart-expanded .node[data-drop-hover="blocked"] > .hm-orgchart-organization { outline: 3px double var(--hm-orgchart-resolved-fg-secondary); }
+
+/* Chart Only (presentationMode="chart-only"): one white canvas that fills the
+ host container. Default Expanded rules above are unchanged. The host sets
+ the canvas size; without a definite height the 320px minimum applies. */
+:host([data-presentation-mode="chart-only"]) { height: 100%; }
+.hm-orgchart-shell[data-presentation-mode="chart-only"] { height: 100%; background: var(--hm-orgchart-resolved-bg-surface); }
+.hm-orgchart-shell[data-presentation-mode="chart-only"] > .hm-orgchart-content { height: 100%; }
+/* Loading/error status overlays the canvas instead of shifting it. */
+.hm-orgchart-shell[data-presentation-mode="chart-only"] > .hm-orgchart-state { position: absolute; top: var(--hm-orgchart-resolved-spacing-lg); left: var(--hm-orgchart-resolved-spacing-lg); z-index: 1; margin: 0; }
+.hm-orgchart-expanded[data-presentation-mode="chart-only"] {
+ display: block; height: 100%; padding: 0; border: 0; border-radius: 0;
+ background: var(--hm-orgchart-resolved-bg-surface);
+}
+.hm-orgchart-expanded[data-presentation-mode="chart-only"] .hm-orgchart-viewport { height: 100%; min-height: 320px; border: 0; border-radius: 0; }
+.hm-orgchart-expanded[data-presentation-mode="chart-only"] .hm-orgchart-viewport:focus-visible { outline-offset: -2px; }
+/* Live move status remains available to assistive technology only. */
+.hm-orgchart-expanded[data-presentation-mode="chart-only"] .hm-orgchart-move-feedback {
+ position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
+ clip-path: inset(50%); white-space: nowrap; border: 0;
+}
+.hm-orgchart-expanded .hm-orgchart-viewport[data-placement-pending] .hm-orgchart-forest { visibility: hidden; }