feat(org-chart): add core cards and fixed-layout integration
No files matched your search
@@ -0,0 +1,91 @@
|
||||
# HMDesign OrgChart — 개발 전 설계 기준
|
||||
|
||||
> 현재 P1 기준(2026-10-12): 공통 HM Theme와 분리된 독립 디자인을 사용한다. [개별 컴포넌트·스타일 API](standalone-components-p1.md)와 [구현·검증 결과](implementation-report-p1.md)를 따른다. 아래 초기 설계와 v0.1 보고서는 과거 기록이며 Theme 필수 연결·reserved hub/status는 최신 사용법이 아니다.
|
||||
|
||||
상태: **문서 초안 / 사용자 검토 대기 / 구현 미착수**. 기준일: 2026-10-12(KST).
|
||||
|
||||
확정 요구사항은 이번 사용자 요청에서 확정된 범위다. `제안`은 승인 전 설계 후보이며, `TBD`는 정책 또는 기술 검토가 필요한 항목이다. 문서 검증의 PASS는 실행 기능의 PASS를 의미하지 않는다.
|
||||
|
||||
## 1. 목적과 문서 읽기 순서
|
||||
|
||||
여러 업무 화면의 콘텐츠 컨테이너에서 재사용할 Framework-independent OrgChart를 설계한다. 1차 공식 개발 대상은 **Vanilla HTML/JavaScript와 React 두 환경**이다. 표준 DOM API와 JavaScript/TypeScript 기반 Core를 우선하고 React는 별도 Wrapper로 연결한다. Core는 framework runtime과 HM React Component를 직접 import하지 않는다. 특정 Dashboard, Header 높이 또는 Navigation에 종속시키지 않는다. JSON과 향후 REST API가 같은 계약을 제공하며 각 환경의 공개 사용 방식은 유지해야 한다.
|
||||
|
||||
1. 이 문서: 확정 범위와 기존 기준과의 차이.
|
||||
2. [Architecture](architecture.md): 계층별 책임, 의존성, 공개 사용 방식 초안.
|
||||
3. [Data Contract](data-contract.md): 정규화 계약, TypeScript·JSON Schema 초안, 검증·집계.
|
||||
4. [UI Composition](ui-composition.md): 콘텐츠 구성, HM 재사용, 컨테이너·접근성.
|
||||
5. [Integration](integration.md): 엔진 공식 근거, React·Theme 연동, JSON → API 교체.
|
||||
6. [Implementation Plan](implementation-plan.md): 단계별 완료 조건, Storybook, TBD·리스크, 이번 문서 검증 기록.
|
||||
7. [Framework Integration](framework-integration.md): Vanilla·React 사용 계약과 예시, 생명주기, CSS·Theme·배포 검증, 미지원 환경.
|
||||
|
||||
## 2. 작업 환경 확인
|
||||
|
||||
읽기 전용 확인 결과, 다음 환경이 존재한다.
|
||||
|
||||
- 실제 Git Root·작업 경로: `D:\디자인시스템\HMDesign-OrgChart`.
|
||||
- 현재 브랜치: `feature/orgchart-package`.
|
||||
- 시작 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c`.
|
||||
- 공식 origin(fetch/push): `https://gitea.hmac.kr/b25027/HMDesign.git`.
|
||||
- 운영 Worktree: `D:\디자인시스템\HMDesign`의 `main`. 기존 Worktree를 재사용하며 새 Worktree는 생성하지 않았다.
|
||||
- 시작 시 Local `main`, Feature, 두 remote-tracking ref와 `git ls-remote`의 원격 두 브랜치 SHA가 모두 위 HEAD와 일치했다. `main...feature/orgchart-package`의 좌/우 독자 Commit 수는 `0 / 0`이다.
|
||||
- 시작 시 개발·운영 Working Tree는 모두 깨끗하고 staged 변경도 없었다.
|
||||
- 루트 [package.json](../../package.json)의 npm workspaces는 `packages/*`; 현재 패키지는 `@hm-design/ui`, `@hm-design/center-graphic`이다. OrgChart 패키지는 아직 없다.
|
||||
- 루트 React/React DOM은 `19.3.0`, Storybook은 `10.6.1`, Tailwind는 `4.3.3`이다. 이는 저장소 manifest 확인이며 설치 상태·호환성 검증 결과가 아니다.
|
||||
- 기존 문서는 `docs/components`, `docs/patterns`, `docs/guidelines`, `docs/reports`, `docs/center-graphic`으로 구분된다. 독립 도메인인 [Center Graphic](../center-graphic/README.md)의 선례에 맞춰 `docs/org-chart`를 신규 설계 문서 위치로 선택했다.
|
||||
|
||||
이번 작업은 이 디렉터리의 Markdown 신규 작성만 허용한다. 구현, 설치, package/lockfile·Theme·Component·Pattern·Storybook 변경, Stage·Commit·Push·Merge는 수행하지 않는다. 운영 프로젝트와 `main`도 변경하지 않는다.
|
||||
|
||||
## 3. 확정 요구사항
|
||||
|
||||
- 엔진은 `dabeng/OrgChart`를 사용한다. 채택할 정확한 버전·배포 모듈 방식은 기술 검증 후 확정한다. [공식 데모](https://dabeng.github.io/OrgChart/), [공식 저장소](https://github.com/dabeng/OrgChart).
|
||||
- 포함 콘텐츠: 제목·설명, 최상위 리더, 중앙 허브, 부문·실·Div, 셀·팀, 직원 프로필, 조직 계층·연결선, 조직 간 추가 관계, 팀별 인원, 파견·휴직 그룹, 관계사별 인원, 전체 인원 집계.
|
||||
- 제외: Header, GNB, LNB, Dashboard Header, 페이지 전체 Layout, Routing, 신규 페이지 등록, 전역 Navigation, 상위 앱 인증 처리.
|
||||
- 최신 추가 요청을 우선하여 Core 노드는 Framework-independent HTML/CSS로 표현하고 HM Theme를 CSS Custom Properties로 연결한다. 공개 React Component는 React 호스트/Adapter의 적합한 영역에서 재사용을 검토하고 Core에는 직접 import하지 않는다. 기존 Color/Semantic Token·Typography·Spacing·Radius 기준을 참조하되 기존 Theme·Component·Pattern이나 공통 디자인 규칙은 변경하지 않는다.
|
||||
- 조직·직원·관계사·추가 관계·표현 설정·Metadata를 ID 기반으로 분리한다. 엔진용 중첩 트리는 ViewModel에서 파생하며 원본 계약이 아니다.
|
||||
- Data Source → Data Validation → Normalized Data Contract → ViewModel / Adapter → OrgChart UI를 유지한다. JSON Mock, 외부 JSON, 향후 REST API 공급 방식은 같은 계약을 제공한다.
|
||||
- 조직·직원·관계·표현 설정 변경은 가능한 한 데이터만 바꿔 반영한다. 집계는 직원 원본에서 계산한다. 미확정 집계 정책을 코드 기본값으로 숨기지 않는다.
|
||||
- Validation 실패는 제어된 Error 상태로 전환하고 화면 전체 비정상 종료를 방지한다.
|
||||
- 검토 너비는 390/768/1280/1440px이다. 컨테이너 기준으로 검토하고 모바일 전략은 기술 검토 후 확정한다.
|
||||
- Storybook은 OrgChart 콘텐츠 단독·가상 데이터 검증용으로 설계한다. Header/Dashboard 조합 Story는 제외한다.
|
||||
- 실제 API 명세가 없으므로 Endpoint·인증 방식·권한·캐시·재시도 정책은 TBD다. 실제 인물·개인정보는 재사용하지 않는다.
|
||||
- 1차 공식 개발 대상은 Vanilla HTML/JavaScript와 React다. Vue·Angular·기타 framework는 이번 개발 범위에서 제외하고 Core의 향후 확장 가능성만 유지한다. 공식 개발 대상 지정과 실제 지원 완료는 구별하며 현재 실행 지원은 모두 NOT VERIFIED다.
|
||||
- Core는 엔진·데이터·Validation·Data Source Adapter·집계·Mount/Update/Reload/Destroy를 공통 관리한다. npm·ESM·브라우저 번들·별도 CSS·타입 선언·Framework Adapter 배포를 검증한다. Theme 미연결 정책은 TBD다.
|
||||
- 두 환경은 동일 JSON 계약·Data Source Interface·Validation·집계·노드 UI·HM Theme CSS Variables·차트 인터랙션을 사용한다. React Wrapper에 별도 엔진/노드 디자인/업무 로직을 중복 구현하지 않는다.
|
||||
|
||||
## 4. 제안과 자료 한계
|
||||
|
||||
- 패키지 경로 `packages/org-chart`, 이름 `@hm-design/org-chart`는 **제안 유지**다. `packages/*`와 양립 가능함을 확인했지만 이름·공개 Export·의존성 승인이나 생성은 하지 않았다.
|
||||
- 최신 확정 요청의 우선 검토안은 `@hm-design/org-chart` Core + `@hm-design/org-chart-react` Wrapper 두 패키지다. 실제 분리는 기존 build 연결과 의존성 검증 후 최종 결정한다.
|
||||
- 문서의 함수·Interface·Schema·폴더·Story 이름은 구현 초안이다. 현재 공개 API처럼 import하거나 사용하지 않는다.
|
||||
- Workdesk 조직도 캡처를 시각 레퍼런스로 사용하는 요구는 확정이다. 그러나 이번 첨부에는 작업 지시 텍스트만 있고 캡처 파일은 없다. 콘텐츠 8영역은 사용자 설명에서 도출했다. 실제 카드 크기·색상·곡선·배치·시각적 일치는 **NOT VERIFIED**이며 추정으로 확정하지 않는다.
|
||||
- 가상 직원은 `가상직원 가`처럼 예시임을 식별할 수 있는 명칭을 사용한다. 사진은 null/fallback 또는 승인된 가상 asset만 사용한다.
|
||||
|
||||
## 5. 기존 지침과 적용 차이
|
||||
|
||||
기존 기준은 [AGENTS.md](../../AGENTS.md), [화면 선택·완료 계약](../guidelines/ai-screen-contract.md), [공개 인덱스](../guidelines/component-pattern-reference.md), [현재 추가 수정 기준](../guidelines/publisher-additional-review-2026-10-10.md), [Runtime Theme](../guidelines/runtime-theme.md)를 참조한다. 공통 기준 전체를 복제하지 않는다.
|
||||
|
||||
- **경로·브랜치·Git 정책 충돌**: 기존 AGENTS는 `HMDesign-Gitea-Transfer/main` 수정과 검증 후 Commit/Push를 요구한다. 이번 요청은 `HMDesign-OrgChart/feature/orgchart-package`의 문서 전용, 운영 `main` 보존, Commit/Push 금지다. 이번 명시적 요청을 우선 적용하며 기존 AGENTS는 수정하지 않는다. 이 예외를 향후 구현·릴리스 권한으로 일반화하지 않는다.
|
||||
- **Storybook 범위 해석**: 기존 규칙은 신규 업무 전체 화면 등록과 대표 Story 교체를 금지한다. 이번 요청은 신규 패키지의 콘텐츠 단독 Story 설계다. 이를 컴포넌트/도메인 검증으로 분리하며 전체 업무 화면을 등록하지 않는다. 미래 코드 등록 전에 분류·경로를 승인받는다.
|
||||
- **Navigation·Dashboard 규칙 적용 범위**: 페이지 소비자는 기존 단일 Navigation 규칙을 따른다. 콘텐츠 패키지가 모드를 선택하거나 Dashboard 48px Header·960px 최소 폭을 내장하지 않는다. 상위 컨테이너 제약은 소비자가 제공한다.
|
||||
- **Guard 범위**: 이번 산출물은 Markdown만 있고 React target/bootstrap이 없다. 화면 생성 Guard를 억지로 실행하거나 PASS를 만들지 않는다. 미래 UI 검증의 적합한 target과 범위는 [Implementation Plan](implementation-plan.md)에서 별도로 다룬다.
|
||||
- **CENTER 선례의 차이**: OrgChart Core의 framework 독립성과 HM CSS Token 재사용을 함께 유지한다. CENTER의 전용 Token 정책을 복사하지 않는다.
|
||||
- **Framework 요구와 기존 React 원칙 충돌**: 기존 AGENTS의 신규 React 화면·HM Component 렌더링 원칙은 최신 추가 요청의 Core 독립성·HTML/CSS 노드 조건에 맞춰 제한한다. Core 표준 DOM 표현을 이번 OrgChart 전용 예외로 기록하며 기존 지침을 자동 수정하지 않는다. React 업무 화면 소비자는 적합한 HM 공개 Component를 계속 사용한다. Vanilla 배포는 기존 Runtime Portability 계약과 함께 검증한다.
|
||||
|
||||
## 6. 확정 요구사항 추적
|
||||
|
||||
- A 프로젝트 목적·패키지 제안 → 이 문서, Architecture §1.
|
||||
- B 콘텐츠 범위·페이지 제외 → 이 문서 §3, UI Composition §1.
|
||||
- C HM 재사용 → UI Composition §2, Integration §3.
|
||||
- D 공급 계층 → Architecture §2, Integration §4.
|
||||
- E 데이터 모델 → Data Contract §1–3.
|
||||
- F 데이터 변경·유지보수 → Architecture §4, Integration §4.
|
||||
- G Validation → Data Contract §4.
|
||||
- H 인원 집계 → Data Contract §5.
|
||||
- I 엔진 기술 검토 → Integration §1–2.
|
||||
- J 화면 8영역·가상 데이터 → UI Composition §1, Data Contract §3.
|
||||
- K 반응형·컨테이너 → UI Composition §3.
|
||||
- L 콘텐츠 Storybook → Implementation Plan §2.
|
||||
- M API 전환 점검 → Integration §4–5.
|
||||
- 추가 Framework-independent 요구 → Architecture 전체, Framework Integration 전체, Implementation Plan §1–3.
|
||||
|
||||
문서화 다음 단계는 사용자 검토·승인이다. 이 문서 작성만으로 개발, 설치 또는 Commit/Push가 승인되지는 않는다.
|
||||
@@ -0,0 +1,117 @@
|
||||
# OrgChart Architecture
|
||||
|
||||
상태: 개발 전 설계. 확정 범위·환경·우선순위는 [README](README.md)를 따른다. 최신 추가 요청에 따라 Framework-independent Core를 기준으로 한다.
|
||||
|
||||
## 1. 독립 Core와 패키지 제안
|
||||
|
||||
확정: Core는 React·Vue runtime에 의존하지 않는다. 표준 DOM API와 JavaScript/TypeScript를 사용하고 dabeng/OrgChart 엔진을 Core에서 관리한다. 데이터 계약·Validation·Data Source Adapter·집계는 공통이다. Routing·로그인·Navigation·상위 Layout은 소유하지 않는다.
|
||||
|
||||
`packages/org-chart` / `@hm-design/org-chart`는 제안이다. npm workspaces `packages/*`와 양립할 수 있음을 확인했지만 생성·명칭 승인은 하지 않았다.
|
||||
|
||||
우선 검토안은 Core `@hm-design/org-chart`와 React Wrapper `@hm-design/org-chart-react` 두 패키지다. 경로 후보는 `packages/org-chart`와 `packages/org-chart-react`다. workspace·build 연결과 Core 소비에 React가 따라오지 않는 dependency·bundle 경계를 입증하고 최종 분리를 결정한다. 같은 패키지의 `/react` subpath는 우선안이 불가능할 때의 비교 항목만 남긴다. Vue·Angular·기타 framework는 이번 범위에서 제외한다. 실제 분리·경로·Export는 승인 전이다.
|
||||
|
||||
향후 책임별 구조 예시이며 이번에는 파일을 만들지 않는다.
|
||||
|
||||
```text
|
||||
packages/org-chart/ 제안: framework-independent Core
|
||||
src/
|
||||
index.ts 공개 Core facade와 type
|
||||
data/ 계약·Validation·집계(DOM도 비의존)
|
||||
sources/ JSON Mock / 외부 JSON 공급
|
||||
view-model/ ID index·파생 트리·표시 모델
|
||||
engine/ dabeng adapter·DOM·생명주기
|
||||
renderers/ 표준 DOM 노드·콘텐츠·안전한 텍스트
|
||||
styles/ 컨테이너 scope·Theme mapping·Overlay
|
||||
브라우저 entry·빌드 설정 승인 후 결정
|
||||
packages/org-chart-react/ 우선 검토안: React 연결만 담당
|
||||
src/org-chart/ 콘텐츠 단독 Story·가상 fixture 제안 위치
|
||||
```
|
||||
|
||||
Core는 `@hm-design/ui` React API·`@hm-design/center-graphic`을 import하지 않는다. HM Theme CSS는 호스트의 별도 제공 또는 승인된 standalone 배포 경로로 연결한다. Theme 때문에 React peer를 가진 UI 전체 패키지 설치를 Vanilla의 필수 조건으로 만들지 않는다. 엔진 dependency의 버전·포함 범위는 TBD다. React Wrapper만 React/React DOM peer를 검토한다.
|
||||
|
||||
루트 [build-package.mjs](../../scripts/build-package.mjs)는 현재 `ui`, `center-graphic`만 허용한다. workspace 자동 탐색만으로 신규 빌드가 완성되지 않는다. 미래 별도 build 또는 최소 기존 빌드 연결 승인이 필요하다. 배포·의존성 격리는 [Framework Integration](framework-integration.md)에서 검증한다.
|
||||
|
||||
## 2. 데이터 흐름과 계층 책임
|
||||
|
||||
```text
|
||||
JSON Mock / 외부 JSON / 미래 REST API
|
||||
↓ Data Source Adapter: 원본 필드 매핑
|
||||
동일 normalized 계약 후보(unknown)
|
||||
↓ Data Validation: 구조·참조·정책 검증
|
||||
검증 성공한 Normalized Data Contract(OrgChartData)
|
||||
↓ ViewModel / Engine Adapter
|
||||
조직별 표시 모델·집계·engine용 파생 트리·추가 relation 모델
|
||||
↓ Framework-independent Core
|
||||
DOM 콘텐츠·engine 계층 영역·Relation Overlay 후보
|
||||
↑ 동일 Core 생명주기 호출
|
||||
Vanilla 호스트 / React Wrapper (동일 Core 재사용)
|
||||
```
|
||||
|
||||
Source 매핑은 Validation 이전에 공통 계약 후보를 만든다. 검증 후 신뢰된 계약과 구별한다. TypeScript cast만으로 신뢰하지 않고 모든 Source가 같은 runtime validator를 거친다.
|
||||
|
||||
- Data Source: 데이터를 읽고 매핑한다. 네트워크·취소·향후 캐시를 담당하며 engine DOM이나 framework를 알지 못한다.
|
||||
- Validation: Schema Version·필수 값·ID·참조·순환·상태·관계·입력 안전성을 검사한다. 성공 데이터 또는 위치별 issue를 반환한다.
|
||||
- Normalized Contract: 조직·직원·관계사·관계·표현·Metadata를 분리한다. engine용 `children`·`relationship`·`className`은 저장하지 않는다.
|
||||
- ViewModel / 집계: index·하위 조직·정렬·leader·직원 그룹·집계를 순수 변환한다. 모든 환경이 같은 함수를 사용한다.
|
||||
- Engine Adapter: 복사한 파생 트리만 전달하며 DOM mount/update/dispose를 관리한다. 원본 수정·fetch·공통 UI 변경은 금지한다.
|
||||
- Core facade / DOM Renderer: 요청 경쟁, Validation, 상태, 콘텐츠 DOM, 인스턴스 ID, 생명주기·Overlay를 관리한다. 텍스트는 `textContent` 등 안전한 경로로 처리한다.
|
||||
- React Wrapper(Framework Adapter): React mount/effect/ref/cleanup과 Props를 Core에 연결한다. Validation·집계·engine·노드 디자인·인터랙션 재구현은 금지한다. 다른 framework Adapter는 이번에 설계 구체화/구현하지 않고 동일 facade의 향후 확장성만 보존한다.
|
||||
- 상위 앱: 컨테이너·Theme bootstrap·Routing·로그인/권한·API client를 소유한다. React 호스트는 적합한 HM 공개 Component 조합을 사용할 수 있다.
|
||||
|
||||
## 3. 공개 생명주기 초안
|
||||
|
||||
현재 Export가 아닌 제안이다. 데이터 type은 [Data Contract](data-contract.md)와 연결한다. `HTMLElement`는 DOM 경계에만 사용한다.
|
||||
|
||||
```ts
|
||||
interface OrgChartDataSource {
|
||||
load(context: { signal: AbortSignal }): Promise<unknown>;
|
||||
}
|
||||
type ValidationIssue = { code: string; path: string; message: string };
|
||||
type OrgChartIssue = ValidationIssue | {
|
||||
code: "SOURCE_LOAD" | "SOURCE_PARSE" | "SOURCE_MAP";
|
||||
path: "dataSource";
|
||||
message: string;
|
||||
};
|
||||
type OrgChartState =
|
||||
| { status: "loading" }
|
||||
| { status: "empty" }
|
||||
| { status: "ready"; dataVersion: string; aggregation: AggregationResult }
|
||||
| { status: "error"; issues: readonly OrgChartIssue[] };
|
||||
interface OrgChartOptions {
|
||||
dataSource: OrgChartDataSource;
|
||||
aggregationPolicy?: ApprovedAggregationPolicy;
|
||||
interaction?: { pan: boolean; zoom: boolean }; // 공개 옵션 후보, 기본값 TBD
|
||||
onStateChange?: (state: OrgChartState) => void;
|
||||
}
|
||||
interface OrgChartHandle {
|
||||
mount(container: HTMLElement): Promise<void>;
|
||||
update(input: unknown): Promise<void>;
|
||||
reload(source?: OrgChartDataSource): Promise<void>;
|
||||
destroy(): void;
|
||||
}
|
||||
declare function createOrgChart(options: OrgChartOptions): OrgChartHandle;
|
||||
```
|
||||
|
||||
`update`는 normalized 스냅샷 후보를 검증·반영하고 `reload`는 현재 또는 새 Source를 읽는다. 두 동작은 동일 Validation·집계·render 경로를 사용한다. 데이터 실패는 state callback으로 전달해 페이지 종료를 방지한다. 호출 순서·Promise·정리 규칙은 [Framework Integration](framework-integration.md)을 따른다.
|
||||
|
||||
구조·참조·cycle·보안의 fatal Validation issue는 차트 Error다. 집계 정책 미확정은 차트 Error와 구별하여 ready의 AggregationResult `unavailable`로 전달하는 제안이다. Source 로딩·JSON 구문·매핑 실패도 제어된 Error지만 code/path로 Validation 오류와 구별하고 서버 원문을 전달하지 않는다. warning·업무 승인 필요 issue의 공개 전달 형식과 치명도는 API 승인 전 확정한다.
|
||||
|
||||
API Source로 전환해도 facade·각 framework의 사용 구조는 유지하고 Source만 바꾼다. 인증은 호스트 client가 처리한다. 검색·다운로드·편집·추가 메뉴·상세 모달은 이번 범위가 아니다.
|
||||
|
||||
## 4. 데이터 변경과 갱신
|
||||
|
||||
조직 추가/삭제·명칭·parent 이동, 직원 추가/삭제·조직 이동·직위/직책·사진·상태·관계사 변경, relation 변경, 기준일·제목 변경은 스냅샷 → Validation → ViewModel 재계산으로 반영한다. 전체 재계산 방식을 우선 검토하고 정렬은 Data Contract의 초안을 따른다.
|
||||
|
||||
Source 전환·reload·update마다 이전 작업을 취소하고 revision을 비교해 늦은 응답을 무시한다. Metadata 버전이 같아도 새 스냅샷은 검증한다. engine에는 복사본만 전달한다. 확장 상태·focus·zoom 보존은 PoC 후 결정하며 복원 키는 안정된 도메인 ID다.
|
||||
|
||||
검증·집계·render는 Core가 소유한 동일 불변 스냅샷을 사용한다. JSON Source의 캡처 시점은 생성 시점으로 제안하고 update는 호출 시 입력을 확보한다. 최종 DOM/state 반영 직전에도 revision·destroy 상태를 검사한다. consumer가 원본 객체를 나중에 바꿔 이미 반영한 화면·숫자가 암묵적으로 바뀌면 계약 실패다.
|
||||
|
||||
데이터만 바꾸는 범위는 승인된 계약의 유형·상태·표현 옵션 안이다. 새 업무 정책·관계 의미·카드 기능은 계약/UI 버전 변경이 필요할 수 있다. 모든 미래 변경을 무조건 무코드로 약속하지 않는다.
|
||||
|
||||
## 5. 엔진과 콘텐츠 경계
|
||||
|
||||
엔진은 조직 parent-child와 기본 계층 연결선을 담당한다. 직원은 조직 ID로 조회해 노드/그룹에 표시하고 리더를 조직으로 중복 삽입하지 않는다.
|
||||
|
||||
중앙 허브가 실제 조직인지 설명 콘텐츠인지 TBD다. 임시 visual root는 내부에서만 생성하고 집계에서 제외한다. 다중 root 처리도 미확정이다. `OrganizationRelation`은 parent-child와 별도이며 engine `relationship` 플래그로 치환하지 않는다. 특수 곡선·교차 관계·고정 배치는 별도 Overlay/Layout 후보로 검토한다. 추가 relation은 집계 분모를 변경하지 않는다.
|
||||
|
||||
완료 조건과 미결정 의존성은 [Implementation Plan](implementation-plan.md)을 따른다.
|
||||
@@ -0,0 +1,87 @@
|
||||
# HMDesign OrgChart P1 — 카드 디자인 1단계 결과
|
||||
|
||||
RESULT: **PASS — 요청한 카드 디자인 범위**. 원본 Guard는 기존 Token 추적 문제로 **FAIL**이며 이를 전체 릴리스 PASS로 치환하지 않았다.
|
||||
|
||||
## 작업 정보
|
||||
|
||||
- 작업 폴더: `D:\디자인시스템\HMDesign-OrgChart`
|
||||
- 브랜치: `feature/orgchart-package`
|
||||
- 작업 전/후 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c`
|
||||
- Started At: 2026-10-12 12:34:53 KST
|
||||
- Completed At: 2026-10-12 12:56:20 KST
|
||||
- Actual Duration: 21분 27초
|
||||
- Variance: 최초 예상 하한 30분 대비 8분 33초 단축 / 상한 45분 대비 23분 33초 단축.
|
||||
- Estimated Duration: 최초 30–45분. 대표자·사업관리 추가 요청 후 40–60분으로 조정.
|
||||
- 기존 package.json/package-lock.json 수정과 OrgChart 미추적 구현을 보존했다. Commit/Push/Merge/브랜치 생성/main 수정은 하지 않았다.
|
||||
|
||||
## 구현 결과
|
||||
|
||||
1. **SVG 자산 방식**: 전달된 10개 SVG를 원본 그대로 복사했다. Vite `?inline`으로 ESM/IIFE에 포함해 HTML/React/file://에서 외부 경로 의존 없이 사용한다. 배경은 장식용 img(`alt=""`, `aria-hidden`, `object-fit:contain`)이며 SVG의 path·gradient를 유지한다. 기존 박스 테두리·배경·우측 가상 장식은 자산 variant에만 제거한다.
|
||||
2. **조직별 매핑**: `department-card-assets.ts`에서 사업관리·경영기획·디자인기획·솔루션영업·인재성장·통합시스템 6개 부서를 매핑한다. 하위 셀/팀은 실제 parentId를 따라 해당 부서 자산을 상속한다. 사용자 승인에 따라 솔루션통합팀 SVG를 기존 solution-sales에 연결하며 조직명/JSON은 바꾸지 않았다. 알려지지 않은 조직은 기존 카드 디자인을 유지한다.
|
||||
3. **대표자**: 본문으로 전달된 146×74 SVG를 `대표자.svg`로 저장했다. `representative-card-asset.ts`로 부서와 별도 관리하고 기존 topLeader 독립 슬롯에만 사용한다. Full Composition의 최상단 가상리더 00 카드 1개에 적용했다.
|
||||
4. **부서 조직장/직원**: 기존 organization.leaderId 및 kind 판별을 재사용한다. 조직장 146×68, 일반 직원 108×52이며 하위 IS/디자인 팀 리더도 146×68이다. 판별 규칙이나 데이터 계약을 변경하지 않았다. 파견·휴직 보조 영역의 카드·배치는 유지했다.
|
||||
5. **사업관리 신규 벡터**: PNG(`D:\디자인시스템\카드 이미지\사업관리`, 확장자 없음, 408×191)에서 외곽 1,048개 RGBA 표본과 내측 5,619개 표본을 사용했다. 외곽 색상 `#DEE6F0 / #A8BBD1 / #577BA5`, 마지막 alpha 0.6; 내측 `#FFFFFF / #E0E6F4`. 기존 같은 크기 카드의 path와 곡선을 재사용하고 PNG에서 내측 그라데이션 방향도 복원했다. 두 자산에 텍스트·로고·사진·래스터는 없다. 원본 SHA-256은 `a8353bbf3869e4935c78a6774741d304836deb427d290586b439277022b2695f`다.
|
||||
6. **데이터 레이어**: 이름·직위·직책·상태·프로필·실제 회사 로고 renderer를 유지했다. 새 작은 카드에만 데이터 폰트·간격을 맞추고 로고 영역을 14px 고정 높이로 두어 숨겨진 fallback의 줄바꿈이 이름 영역과 겹치지 않게 했다. 로고의 투명 배경·정상 이미지 시 fallback 숨김은 유지했다.
|
||||
7. **Storybook**: 사용자 메뉴 5개와 내부 Story 8개를 유지했다. Department Colors는 대표자 1종과 6개 부서의 2종, 총 13개 카드 specimen·실제 크기·자산 출처를 표시한다. 기존 대비 MODEL/미승인 후보 24개 출력도 유지했다. Full Composition은 같은 Core renderer를 사용한다. 새 메뉴를 만들거나 기존 Story를 대체하지 않았다.
|
||||
8. **독립 패키지**: 자산·스타일은 @hm-design/org-chart 내부에 한정했다. React Wrapper/공개 exports/JSON/공통 Theme/AI Guard/허브/Summary/관계선은 변경하지 않았다. Navigation은 기존 Header 없는 Core 콘텐츠를 유지했다. 이번 작업에는 목록 검색 요구가 없어 FilterBar를 추가하지 않았다.
|
||||
|
||||
## 변경 파일 — 이번 작업만
|
||||
|
||||
신규:
|
||||
|
||||
- `packages/org-chart/src/assets/cards/` SVG 13개(전달 10개, 대표자 1개, 사업관리 생성 2개) 및 README.md.
|
||||
- `packages/org-chart/src/core/department-card-assets.ts`
|
||||
- `packages/org-chart/src/core/representative-card-asset.ts`
|
||||
- `examples/org-chart/create-business-card-assets.py`
|
||||
- `examples/org-chart/verify-card-design.mjs`
|
||||
- `docs/org-chart/card-design-stage1.md`
|
||||
|
||||
수정:
|
||||
|
||||
- `packages/org-chart/src/core/employee-card.ts` — 장식 SVG 레이어/내부 자산 옵션.
|
||||
- `packages/org-chart/src/core/render.ts` — 조직장·직원 부서 자산 선택.
|
||||
- `packages/org-chart/src/core/org-chart-content.ts` — 대표자 전용 자산.
|
||||
- `packages/org-chart/src/core/department-colors.ts` — 카드 specimen/크기/출처/대표자 표시.
|
||||
- `packages/org-chart/src/core/vendor.d.ts` — svg?inline 타입 선언.
|
||||
- `packages/org-chart/src/styles/employee-card.css` — 자산 variant 크기/장식 제거/데이터 레이어 조정.
|
||||
- `packages/org-chart/src/styles/org-chart-content.css` — specimen caption 및 대표자 미리보기 정렬.
|
||||
|
||||
생성 산출물: packages/org-chart/dist, examples/org-chart/dist, examples/org-chart-react/dist, storybook-static. 검증 근거는 기존 기록과 별도 `test-results/org-chart-card-design/`에 보존했다.
|
||||
|
||||
## 최종 통합 검증
|
||||
|
||||
- Core Typecheck **PASS**: `npm.cmd run typecheck:org-chart`.
|
||||
- Story/preview consumer Typecheck **PASS**: `npm.cmd exec -- tsc -p tsconfig.app.json --noEmit`.
|
||||
- Core ESM/IIFE/types/CSS Build **PASS**: `npm.cmd run build:org-chart`.
|
||||
- HTML prepare / React demo Build **PASS**. React Wrapper 소스 변경이 없어 기존 Wrapper 검증을 반복하지 않았다.
|
||||
- Storybook production Build **PASS**. 기존 500kB chunk 경고는 남아 있으며 빌드 실패는 없다.
|
||||
- 최종 Chrome **231/231 PASS**, console/runtime error **0**.
|
||||
- Department Colors / Full Composition / HTML / React / file:// classic 각각 390/768/1280/1440px에서 로딩·원본 크기·배경 장식 제거·실제 DOM Range 텍스트와 프로필/로고의 카드 경계·겹침·페이지 overflow를 확인했다.
|
||||
- 6개 부서 전체 매핑, 대표자 단독 top slot, 사업관리 조직장/직원 구분, 기존 status 카드 보존 **PASS**. 대표자/조직장/직원 프로필에 로컬 이미지 fixture를 넣어 경계와 로딩을 확인했으며 원본 JSON은 수정하지 않았다.
|
||||
- HTML/React/file:// 카드의 computed geometry/장식/이미지 표시/위치 판정 일치 **PASS**. 전체 화면 픽셀 동일성이나 모든 사용자 데이터 조합의 적합성을 뜻하지 않는다.
|
||||
- 원본 SVG 10개의 SHA-256 동일 **PASS**. Logo/JSON/Wrapper/Story 원본 보존은 preservation.json으로 확인한다.
|
||||
- 사업관리 PNG와 신규 큰 SVG를 Chrome canvas로 원본 408×191 크기에 렌더링해 흰 배경 합성 RGB를 비교했다. 전체 픽셀 채널 평균 오차 **1.113/255**, 95백분위 **1.094/255**. geometry/안티앨리어싱 차이를 포함하며 임의의 지각적 동일성 인증은 아니다. 색상 유사성 검사 **PASS**, 직접 시각 확인 완료.
|
||||
- 기존 Core 데이터·Summary·전체 Guard 회귀는 재실행하지 않았다. 중간 실패는 작은 카드 로고 높이와 PNG 내측 그라데이션 방향에 한정해 수정했으며, 수정된 산출물의 최종 표시를 재검증했다.
|
||||
|
||||
### Guard 원본 결과
|
||||
|
||||
수정된 파일만 PARTIAL/STORYBOOK으로 실행했다. 대표자·사업관리 추가 요청으로 코드 graph가 변경된 후 해당 최종 범위를 한 번 더 실행했고, 기존 결과는 guard-five-departments.json으로 보존했다. 원본 status/staticStatus **FAIL**: TOKEN-001 8건, TOKEN-002 NOT VERIFIED 6건, SCOPE-001 NOT VERIFIED 4건. 기존 품질 보고와 같은 Shadow CSS/setProperty/선택적 입력 추적 문제이며 이 작업에서 공통 검사기를 변경하지 않았다.
|
||||
|
||||
`styleEvidence`: STATIC **PASS**, RUNTIME **NOT VERIFIED**, DESIGN_INTENT **PASS**, FINAL **NOT VERIFIED**. 이 값은 Guard 증거 연결 상태다. 별도 브라우저 PASS가 raw FAIL을 덮어쓰지 않는다. 결합 styleVerification은 생성/승격하지 않았다. 카드 전용 하드코드 geometry는 공통 HM Component override가 아니라 승인된 OrgChart SVG의 원본 크기다.
|
||||
|
||||
## 남은 이슈
|
||||
|
||||
- 검사한 가상 데이터의 텍스트·이미지·로고는 잘림/겹침 없이 표시된다. 매우 긴 소비자 이름·직책·임의 폰트 override는 **NOT VERIFIED**이며 고정 카드 크기에서 추가 정책이 필요할 수 있다.
|
||||
- 작은 실제 로고의 판독성 한계는 남는다. 원본 로고는 crop/recolor하지 않았다.
|
||||
- PNG로부터 SVG 그라데이션을 복원하므로 래스터 안티앨리어싱까지 정확히 같다는 보장은 없다. 투명도 포함 채널 비교와 원본 SHA를 보존했다.
|
||||
- 허브·관계선·부서 헤더 대비의 기존 REVIEW와 Guard 추적 FAIL은 다음 별도 작업 대상이다. 전체 Workdesk 레이아웃 비교/통계 재설계/운영 API/전체 접근성 검증은 이번 범위에서 **NOT VERIFIED**.
|
||||
|
||||
미리보기: `http://127.0.0.1:6028/?path=/story/orgchart--department-colors`, `http://127.0.0.1:6028/?path=/story/orgchart--full-composition`. 직접 열기 결과: `examples/org-chart/dist/p1-classic.html`.
|
||||
|
||||
## 완료 기록
|
||||
|
||||
Started At: **2026-10-12 12:34:53 KST**. Completed At: **2026-10-12 12:56:20 KST**. Actual Duration: **21분 27초**. Estimated Duration: **최초 30–45분**, 추가 요청 후 **40–60분**. Variance: 최초 예상 대비 **하한 −8분 33초 / 상한 −23분 33초**. 시간은 실제 clock 도구로 확인했다.
|
||||
|
||||
최종 SHA 보존 확인: baseline 111개 중 승인 범위 수정 7개, 나머지 104개 유지, 삭제 0개. 신규 소스/자산/문서 19개. root manifests/기존 Story/JSON/로고/React Wrapper/Guard/기존 보고서의 시작값은 유지했다. staged file 0개, branch/HEAD 동일. 근거: `test-results/org-chart-card-design/preservation.json`.
|
||||
|
||||
Guard snapshot 후 자산 출처를 설명하는 주석과 문서만 정리했다. 실행 동작과 Guard 분석 대상의 Token/CSS 구현은 최종 검사 이후 변경하지 않았다. 정상 Typecheck/Build/browser 검사를 문서 편집 때문에 반복하지 않았다.
|
||||
@@ -0,0 +1,122 @@
|
||||
# OrgChart 카드 디자인 2단계 — Typography 및 내부 콘텐츠
|
||||
|
||||
> P3.2 후속 확정: 대표자 카드에는 모든 직책·역할·상태 뱃지와 빈 뱃지 행을 생성하지 않는다. `대표이사`·`홍 길 동`, 승인 카드 146×74 및 기존 Typography를 유지한다. `대표이사`는 표시 계층에만 적용하며 직원 데이터·팝업 원문과 다른 1Depth/2Depth 뱃지는 보존한다. [현재 표시 계약](ui-composition.md#p32-후속--대표자-뱃지-미사용-2026-10-12)이 아래 과거 기록보다 우선한다.
|
||||
|
||||
> P3.2 최신 계약은 [P3.2 보고서](implementation-report-p3.2.md)를 따른다. 대표자는 그대로, 대형 이름 24px·직위 18px, 소형 이름 18px·직위 15px이다. 기존 가중치·line-height·컬러는 보존한다. readOnly 파견/휴직은 일반 소형의 108×52 카드·24×44 사진·텍스트 레이아웃을 공유하고 전용 회색 색상을 유지한다. 상태 타이틀은 일반 2Depth 기본 20px/700/19.7px, 카드 로고만 기존 실제 표시의 1.5배다. 아래 수치·검증은 역사 기록이다.
|
||||
|
||||
> P3.1 최신 표시 계약은 [P3.1 보고서](implementation-report-p3.1.md)를 따른다. Noto Sans KR을 OrgChart scope에 적용하며 직위 공백은 표시에서만 제거한다. 조회 화면의 `경영진` 직책 뱃지만 제거하고 실제 직위·다른 뱃지·데이터는 보존한다. 사진 없음/실패에는 공통 사각 `person-placeholder.svg`를 표시한다. 아래 P3 및 P2의 수치와 검증은 역사 기록이다.
|
||||
|
||||
> P3 최신 표시 계약: 카드/SVG 크기와 기존 Typography는 유지한다. 표시 이름만 `홍 길 동`, 직위·직책 뱃지는 원문이다. 대표자를 포함하여 직위와 뱃지는 같은 행(Gap 4px), 이름은 아래 행(Gap 0px)이다. 모든 조직 타이틀↔첫 카드 Gap은 8px이다. Workdesk 태그 Border/색상/폰트와 사각형 사진 슬롯을 적용하며, 사진 없음에는 새 프로필 이미지가 없다. 실제 결과는 [P3 보고서](implementation-report-p3.md)에 기록한다. 아래 P2 및 이전 수치·검증은 역사 기록으로 보존한다.
|
||||
|
||||
RESULT: PARTIAL
|
||||
|
||||
지정 Typography·Gap·말줄임·전체 이름 제공 구현과 해당 범위 검증은 PASS입니다. 제공한 Workdesk 캡처 경로의 파일과 폴더가 현재 없어 사진·텍스트·로고의 실제 운영 화면 정렬 일치는 NOT VERIFIED입니다. Guard 원본 FAIL도 유지합니다.
|
||||
|
||||
## 작업 정보
|
||||
|
||||
- 작업 폴더: `D:\디자인시스템\HMDesign-OrgChart`
|
||||
- 브랜치: `feature/orgchart-package`
|
||||
- 작업 전/후 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c` (동일)
|
||||
- Started At: 2026-10-12 12:58:39 KST
|
||||
- Completed At: 2026-10-12 13:17:42 KST
|
||||
- Actual Duration: 19분 3초
|
||||
- Estimated Duration: 30–45분
|
||||
- Variance: 최소 예상 30분보다 10분 57초 짧음
|
||||
- 기존 Working Tree 보존, Stage·Commit·Push·Merge·브랜치 생성 없음.
|
||||
|
||||
## 구현 결과
|
||||
|
||||
1. 대표자: 직위 `#224387 / 20px / 400 / 22px / -0.8px`, 이름 `#071D3A / 24px / 600 / 30px / -0.8px` 적용.
|
||||
2. 그룹장: 직위 `#797979 / 20px / 400 / 22px / -0.8px`, 이름 `#071D3A / 24px / 600 / 30px / -0.96px` 적용.
|
||||
3. 일반 직원: 직위 `#797979 / 15px / 400 / 18px / -0.6px`, 이름 `#071D3A / 20px / 600 / 24px / -0.8px` 적용. 위 값의 순서는 color / font-size / font-weight / line-height / letter-spacing입니다.
|
||||
4. 직위와 이름을 별도 세로 Flex 컨테이너에 두고 gap·margin·padding을 모두 0으로 적용했습니다. 카드 Padding과 사진·텍스트 사이 간격은 보존했습니다.
|
||||
5. 사진은 왼쪽, 직위·이름은 오른쪽, 실제 회사 로고는 우측 상단에 유지했습니다. 기존 직책 배지는 사진 아래쪽 독립 영역으로 배치했습니다. SVG 카드의 상태 텍스트는 접근성 읽기에 유지하고 별도 세 번째 시각 행을 만들지 않았습니다. 파견·휴직 별도 영역의 카드 스타일은 보존했습니다.
|
||||
6. 카드 크기는 대표자 146×74, 그룹장 146×68, 일반 직원 108×52입니다. 프로필 영역은 각각 36×36, 32×32, 24×24로 이전 단계와 동일합니다. 폰트·사진·카드 크기를 축소하지 않았습니다.
|
||||
7. 긴 이름에만 말줄임을 적용합니다. 전체 문자열은 DOM에 유지하고, 실제 Overflow 카드만 Tab 순서에 포함합니다. Hover와 Focus로 같은 Shadow Root의 전체 이름 툴팁을 열고 `aria-describedby`로 연결합니다. 툴팁 위로 포인터를 이동할 수 있고 Escape로 닫을 수 있습니다. ResizeObserver·글꼴 로딩으로 판정을 갱신하고 기존 AbortSignal에서 관찰자·이벤트·툴팁을 정리합니다.
|
||||
8. 기본 가상 이름 41개를 자연스러운 2–3음절 한국어 이름으로 바꿨습니다. 직원 ID·직위·소속·상태·프로필·인원수·JSON 계약은 그대로입니다. 실제 직원 개인정보를 복사하지 않았습니다.
|
||||
|
||||
## Storybook 및 공통 적용
|
||||
|
||||
- Department Colors: 대표자와 6개 조직의 그룹장·직원 카드에 같은 Core Typography 적용. 기존 메뉴 내 “긴 이름 검증 시작/종료”로 대표자·그룹장·직원 각 1개의 긴 이름을 확인할 수 있습니다. 기본값은 짧은 이름입니다.
|
||||
- Full Composition: 최상단 대표자, 6개 조직 그룹장, 일반 직원 카드의 기본 이름·Typography·로고 배치 확인.
|
||||
- 사용자용 메뉴 Logo / Department Colors / Position Summary / Affiliation Summary / Full Composition 5개 유지.
|
||||
- 내부 OrgChart/Content Story 8개 보존. 별도 신규 Story 메뉴 없음.
|
||||
- HTML·React는 같은 Core 렌더러와 전용 CSS를 사용하며, 390/768/1280/1440px의 카드 Computed Style·크기·콘텐츠 측정값이 일치했습니다.
|
||||
- 로컬 미리보기: [Department Colors](http://127.0.0.1:6030/?path=/story/orgchart--department-colors), [Full Composition](http://127.0.0.1:6030/?path=/story/orgchart--full-composition).
|
||||
|
||||
## 변경 파일
|
||||
|
||||
신규 파일:
|
||||
|
||||
- `packages/org-chart/src/core/person-name-overflow.ts`: Overflow 판정·Hover/Focus 툴팁·접근성·정리.
|
||||
- `examples/org-chart/verify-card-content.mjs`: 이번 단계의 브라우저 검증.
|
||||
- `docs/org-chart/card-design-stage2.md`: 이 보고서.
|
||||
|
||||
수정 파일:
|
||||
|
||||
- `packages/org-chart/src/core/employee-card.ts`: SVG 카드의 직위/이름 전용 컨테이너 및 배지 영역.
|
||||
- `packages/org-chart/src/styles/employee-card.css`: 종류별 Typography·Gap·말줄임·Focus·툴팁.
|
||||
- `src/org-chart/org-chart-p1.stories.tsx`: 기존 Department Colors 내 긴 이름 검증 토글.
|
||||
- `examples/org-chart/create-p1-fixture.mjs`: 자연스러운 가상 이름 생성.
|
||||
- `examples/org-chart/data-p1.json`: 이름 필드만 갱신.
|
||||
|
||||
생성 결과: `packages/org-chart/dist/`, HTML/React demo dist, `storybook-static/`, `test-results/org-chart-card-content/`. 공통 Theme·Guard·공개 API·React Wrapper·차트 엔진·허브·Summary 구현은 변경하지 않았습니다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- 변경 패키지 Typecheck: PASS.
|
||||
- Story 소비 코드 Typecheck (`tsconfig.app.json`): PASS.
|
||||
- 변경 패키지 Build (ESM / browser IIFE / types / CSS): PASS.
|
||||
- HTML 준비 및 React demo Build: PASS.
|
||||
- Storybook production Build: PASS. 기존 큰 chunk 경고는 남아 있습니다.
|
||||
- 실제 Chrome 브라우저: **368/368 PASS**, console/runtime 오류 0.
|
||||
- 대표자/그룹장/직원 Computed Style, weight·line-height·letter-spacing·색상·Gap·margin·padding: PASS.
|
||||
- Department Colors 및 Full Composition 렌더링: PASS.
|
||||
- 짧은 이름: 줄바꿈·잘림 없이 표시, 불필요한 Tab 진입 없음.
|
||||
- 긴 이름: Overflow 카드에만 ellipsis·Tab Focus·전체 이름 툴팁, Hover·툴팁 Hover·실제 키보드 Tab·Escape·Focus 유지·접근성 트리 전체 문자열: PASS.
|
||||
- 긴 이름을 짧은 이름으로 복구할 때 툴팁 제거: PASS.
|
||||
- 카드 크기와 프로필 크기 보존, 사진/텍스트/로고/배지 겹침, 가시 텍스트 범위, 페이지 Overflow: PASS. 실제 이미지 로딩 사례도 검증했습니다.
|
||||
- HTML·React 4개 뷰포트 Computed Style/콘텐츠 일치: PASS.
|
||||
- 데이터 비교: 이름 필드 외 값 동일, JSON 계약·직원 수·소속·프로필 보존 PASS.
|
||||
- 시작 파일 130개 중 125개 해시 동일, 누락 없음. 변경은 위 5개 기존 파일뿐입니다.
|
||||
- SVG 13개와 실제 로고 PNG 7개 해시 모두 동일. 이전 품질 보완 파일 및 1단계 보고서 보존.
|
||||
- `git diff --check`: PASS. 기존 Stage 파일 없음, HEAD·브랜치 동일.
|
||||
- 기존 1단계 231개 검사는 재실행하지 않았습니다. 통계·데이터 Core 전체·AI Guard 전체 회귀도 재실행하지 않았습니다.
|
||||
|
||||
증거:
|
||||
|
||||
- `test-results/org-chart-card-content/browser.json`: 브라우저 버전, 368개 결과, 카드별 측정값.
|
||||
- `test-results/org-chart-card-content/preservation.json`: 해시 보존, Story 수, Git 상태.
|
||||
- `test-results/org-chart-card-content/baseline.json`: 시작 파일 해시.
|
||||
- `test-results/org-chart-card-content/data-p1-before.json`: 이름 변경 전 비교 기준.
|
||||
- `test-results/org-chart-card-content/*-normal.png`, `composition-1440.png`, `representative-tooltip-visible.png`: 실제 렌더링 화면.
|
||||
|
||||
### Guard 원본 상태
|
||||
|
||||
요청 파일을 대상으로 PARTIAL / STORYBOOK Guard를 1회 실행했습니다. 원본 `status=FAIL / staticStatus=FAIL`을 보존합니다.
|
||||
|
||||
- TOKEN-001 FAIL 8개: 변경하지 않은 `department-colors.ts` 및 `style-presets.ts`의 OrgChart 전용 동적 CSS 변수 추적.
|
||||
- 이전 1단계 FAIL 항목과 code·file·line·message가 모두 동일합니다.
|
||||
- TOKEN-002 NOT VERIFIED 6개, 패키지 CSS 추적 SCOPE-001 NOT VERIFIED 1개.
|
||||
- styleEvidence: STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED. 기존 Story 레이아웃 CSS의 디자인 의도 항목을 포함한 원본 결과이며, 이번 브라우저 증거로 원본을 덮어쓰지 않았습니다.
|
||||
- `test-results/org-chart-card-content/guard-storybook.json`에 전체 원본 저장.
|
||||
- 이번 카드 동작의 PASS와 Guard 전체 상태를 구분합니다. Guard 수정은 범위에서 제외합니다.
|
||||
|
||||
## 남은 차이 및 NOT VERIFIED
|
||||
|
||||
- `D:\디자인시스템\HMDesign-OrgChart-References\workdesk-orgchart.png`와 상위 References 폴더가 현재 없습니다. 따라서 실제 Workdesk 사진·텍스트·로고 위치, 글꼴 및 미세 정렬 일치는 NOT VERIFIED입니다. 지정 확정 CSS 수치는 우선 적용했습니다.
|
||||
- 기존 OrgChart 글꼴 설정을 유지했습니다. 실제 Workdesk의 글꼴 차이에 따른 시각적 차이는 캡처를 확인한 후 판단해야 합니다.
|
||||
- 브라우저 접근성 트리와 키보드 동작은 PASS이나, 별도의 실제 스크린리더 음성 낭독은 NOT VERIFIED입니다.
|
||||
- Guard의 기존 추적 FAIL/NOT VERIFIED는 미해소입니다.
|
||||
- 긴 이름을 완전히 한 줄로 표시하려면 고정 카드 폭을 초과하므로, 사용자 확정대로 크기·폰트를 유지하고 말줄임과 전체 이름 툴팁을 적용했습니다.
|
||||
- 전체 레이아웃·관계선·허브·통계 재설계는 다음 단계 범위이며 이번에는 변경하지 않았습니다.
|
||||
|
||||
## P2 최신 확정 표시 규칙 — 2026-10-12
|
||||
|
||||
위 2단계 기록과 검증 결과는 보존한다. 조회 전용 Full Composition의 현재 적용 기준은 [P2 보고서](implementation-report-p2.md)다.
|
||||
|
||||
- Depth 1 조직장만 146×68, Depth 2 이하 조직장은 108×52 소형 SVG. 독립 카드와 interactive 모드의 기존 조직장 선택은 유지한다.
|
||||
- 가상 직원의 이름은 표시 계층에서 `홍 길 동`으로 통일하고 직위·직책에도 연속 한글 사이 공백을 적용한다. 원본 데이터 문자열에는 공백을 삽입하지 않는다.
|
||||
- 기존 font-size, font-weight, line-height, 컬러와 내부 직위/뱃지 행 ↔ 이름 행 gap 0px는 유지한다. 글자 간격에 관한 이전 표시보다 이번 확정 지시를 우선한다.
|
||||
- 외부 1Depth 헤더 ↔ 대형 카드 gap은 최종 8px이며, 이전 P2 초안의 10px는 적용하지 않는다. 아래 Depth 헤더 ↔ 소형 카드는 10px다.
|
||||
- 긴 직책의 공백 표시가 회사 로고와 겹치지 않도록 조회 전용 대형 카드의 뱃지를 같은 행 안에서 아래로 정렬하고 로고 위치를 3px 올렸다. SVG·로고 원본, 글자/로고 크기 및 내부 행 높이는 변경하지 않았다.
|
||||
@@ -0,0 +1,261 @@
|
||||
# OrgChart Data Contract
|
||||
|
||||
상태: **JSON Schema / TypeScript 초안, 승인 전**. ID 기반 분리·동일 계약 사용·Validation·원본 집계는 확정 요구다. 아래 필드명·enum·null·버전 값은 제안이다. 모든 환경이 이 계약과 같은 검증·집계를 공유한다. [Architecture](architecture.md), [API 전환](integration.md)을 함께 읽는다.
|
||||
|
||||
## 1. 정규화 모델과 TypeScript 초안
|
||||
|
||||
ID는 엔티티별 배열 안에서 유일한 불투명 문자열이다. 이름·배열 위치로 연결하지 않는다. 조직은 `parentId`, 직원은 `organizationId`·`affiliationId`, 조직장은 `leaderId`, 추가 관계는 두 조직 ID를 참조한다. 서로 다른 엔티티의 같은 ID 문자열은 허용할지 TBD이며, DOM에는 엔티티·인스턴스 prefix를 별도로 붙인다.
|
||||
|
||||
```ts
|
||||
type ID = string;
|
||||
type OrganizationType = "root" | "division" | "office" | "div" | "cell" | "team";
|
||||
type WorkStatus = "active" | "dispatched" | "onLeave";
|
||||
type RelationType = "cooperation" | "support";
|
||||
|
||||
interface Organization {
|
||||
id: ID;
|
||||
parentId: ID | null;
|
||||
name: string;
|
||||
type: OrganizationType;
|
||||
leaderId: ID | null;
|
||||
displayOrder: number;
|
||||
}
|
||||
interface Employee {
|
||||
id: ID;
|
||||
organizationId: ID;
|
||||
affiliationId: ID;
|
||||
name: string;
|
||||
position: string; // 직위. jobTitle(직책)과 구별
|
||||
jobTitle: string | null;
|
||||
profileImage: string | null;
|
||||
workStatus: WorkStatus;
|
||||
}
|
||||
interface Affiliation { id: ID; name: string }
|
||||
interface OrganizationRelation {
|
||||
id: ID;
|
||||
sourceOrganizationId: ID;
|
||||
targetOrganizationId: ID;
|
||||
type: RelationType;
|
||||
}
|
||||
interface PresentationConfig {
|
||||
title: string;
|
||||
description: string | null;
|
||||
hubText: string | null;
|
||||
groups: {
|
||||
showEmployees: boolean;
|
||||
showSummary: boolean;
|
||||
showDispatched: boolean;
|
||||
showOnLeave: boolean;
|
||||
showAffiliations: boolean;
|
||||
};
|
||||
organizationOrder: ID[];
|
||||
}
|
||||
interface Metadata {
|
||||
schemaVersion: "0.1-draft";
|
||||
asOfDate: string; // YYYY-MM-DD, 유효한 실제 날짜
|
||||
dataVersion: string;
|
||||
updatedAt: string | null; // timezone을 포함한 RFC 3339 갱신 시각
|
||||
updateNote: string | null;
|
||||
}
|
||||
interface OrgChartData {
|
||||
organizations: Organization[];
|
||||
employees: Employee[];
|
||||
affiliations: Affiliation[];
|
||||
relations: OrganizationRelation[];
|
||||
presentation: PresentationConfig;
|
||||
metadata: Metadata;
|
||||
}
|
||||
```
|
||||
|
||||
제안된 `root/division/office/div/cell/team`은 최상위/부문/실/Div/셀/팀 표현이다. 실제 조직 유형·업무 상태·관계 유형 vocabulary, 직위 필수 여부, 무소속·관계사 미지정 허용은 **TBD**다. 새로운 enum은 schema/API 버전과 함께 확장하고 알 수 없는 값을 임의 재직 상태로 치환하지 않는다.
|
||||
|
||||
`schemaVersion`은 계약 호환성, `dataVersion`은 스냅샷 식별이다. 서로 대신 쓰지 않는다. 부모 null은 root 후보, leader null은 공석 후보, 이미지 null은 가상 fallback을 뜻하는 초안이다. API에서 필드 생략이 가능한지와 null 허용은 승인 후 확정한다.
|
||||
|
||||
표현 설정은 표시만 바꾸고 원본 소속·집계 분모를 변경하지 않는다. 순서는 제안상 `organizationOrder`에 있는 ID 우선 → 나머지 형제 `displayOrder` → 동률 ID 순서로 결정한다. 전역 목록이 계층 parent를 바꾸지는 않는다. 실제 정렬 우선순위는 TBD다. PresentationConfig에 CSS·HTML·DOM 좌표·API URL을 받지 않는다.
|
||||
|
||||
## 2. JSON Schema 초안
|
||||
|
||||
JSON Schema Draft 2020-12 제안이다. `format`을 실제 검사하는 validator 설정이 필요하다. 아래 구조는 JSON 구문 검증 대상이며 runtime validator 구현 또는 Schema 승인 결과가 아니다. 중복 ID·참조·순환·업무 규칙은 §4의 별도 검증이 필요하다. 모든 필드를 required로 하고 nullable을 명시하는 엄격한 초안이며 API 매핑에서 같은 형태로 변환한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "HMDesign OrgChart draft",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["organizations", "employees", "affiliations", "relations", "presentation", "metadata"],
|
||||
"properties": {
|
||||
"organizations": {"type": "array", "items": {"$ref": "#/$defs/Organization"}},
|
||||
"employees": {"type": "array", "items": {"$ref": "#/$defs/Employee"}},
|
||||
"affiliations": {"type": "array", "items": {"$ref": "#/$defs/Affiliation"}},
|
||||
"relations": {"type": "array", "items": {"$ref": "#/$defs/OrganizationRelation"}},
|
||||
"presentation": {"$ref": "#/$defs/PresentationConfig"},
|
||||
"metadata": {"$ref": "#/$defs/Metadata"}
|
||||
},
|
||||
"$defs": {
|
||||
"ID": {"type": "string", "minLength": 1},
|
||||
"Text": {"type": "string", "minLength": 1},
|
||||
"NullableText": {"anyOf": [{"$ref": "#/$defs/Text"}, {"type": "null"}]},
|
||||
"NullableID": {"anyOf": [{"$ref": "#/$defs/ID"}, {"type": "null"}]},
|
||||
"Organization": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["id", "parentId", "name", "type", "leaderId", "displayOrder"],
|
||||
"properties": {
|
||||
"id": {"$ref": "#/$defs/ID"},
|
||||
"parentId": {"$ref": "#/$defs/NullableID"},
|
||||
"name": {"$ref": "#/$defs/Text"},
|
||||
"type": {"enum": ["root", "division", "office", "div", "cell", "team"]},
|
||||
"leaderId": {"$ref": "#/$defs/NullableID"},
|
||||
"displayOrder": {"type": "integer", "minimum": 0}
|
||||
}
|
||||
},
|
||||
"Employee": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["id", "organizationId", "affiliationId", "name", "position", "jobTitle", "profileImage", "workStatus"],
|
||||
"properties": {
|
||||
"id": {"$ref": "#/$defs/ID"},
|
||||
"organizationId": {"$ref": "#/$defs/ID"},
|
||||
"affiliationId": {"$ref": "#/$defs/ID"},
|
||||
"name": {"$ref": "#/$defs/Text"},
|
||||
"position": {"$ref": "#/$defs/Text"},
|
||||
"jobTitle": {"$ref": "#/$defs/NullableText"},
|
||||
"profileImage": {"anyOf": [{"type": "string", "format": "uri-reference", "minLength": 1}, {"type": "null"}]},
|
||||
"workStatus": {"enum": ["active", "dispatched", "onLeave"]}
|
||||
}
|
||||
},
|
||||
"Affiliation": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["id", "name"],
|
||||
"properties": {"id": {"$ref": "#/$defs/ID"}, "name": {"$ref": "#/$defs/Text"}}
|
||||
},
|
||||
"OrganizationRelation": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["id", "sourceOrganizationId", "targetOrganizationId", "type"],
|
||||
"properties": {
|
||||
"id": {"$ref": "#/$defs/ID"},
|
||||
"sourceOrganizationId": {"$ref": "#/$defs/ID"},
|
||||
"targetOrganizationId": {"$ref": "#/$defs/ID"},
|
||||
"type": {"enum": ["cooperation", "support"]}
|
||||
}
|
||||
},
|
||||
"PresentationConfig": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["title", "description", "hubText", "groups", "organizationOrder"],
|
||||
"properties": {
|
||||
"title": {"$ref": "#/$defs/Text"},
|
||||
"description": {"$ref": "#/$defs/NullableText"},
|
||||
"hubText": {"$ref": "#/$defs/NullableText"},
|
||||
"organizationOrder": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/ID"}},
|
||||
"groups": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["showEmployees", "showSummary", "showDispatched", "showOnLeave", "showAffiliations"],
|
||||
"properties": {
|
||||
"showEmployees": {"type": "boolean"}, "showSummary": {"type": "boolean"},
|
||||
"showDispatched": {"type": "boolean"}, "showOnLeave": {"type": "boolean"},
|
||||
"showAffiliations": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Metadata": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["schemaVersion", "asOfDate", "dataVersion", "updatedAt", "updateNote"],
|
||||
"properties": {
|
||||
"schemaVersion": {"const": "0.1-draft"},
|
||||
"asOfDate": {"type": "string", "format": "date"},
|
||||
"dataVersion": {"$ref": "#/$defs/Text"},
|
||||
"updatedAt": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}]},
|
||||
"updateNote": {"$ref": "#/$defs/NullableText"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
빈 문자열·공백만 있는 필수 값은 runtime Validation에서 거부하도록 제안한다. `minLength`만으로 공백 검사가 끝나지 않는다. 입력 길이·배열 크기·깊이 상한은 실제 데이터 규모 검토 후 TBD로 확정한다.
|
||||
|
||||
## 3. 가상 JSON 스냅샷 예시
|
||||
|
||||
실제 인사 정보와 사진이 없는 예시다. Schema 값은 초안이며 아래 데이터가 운영 계약으로 승인된 것은 아니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"organizations": [
|
||||
{"id": "org-root", "parentId": null, "name": "가상 사업본부", "type": "root", "leaderId": "emp-a", "displayOrder": 0},
|
||||
{"id": "team-a", "parentId": "org-root", "name": "가상 기획팀", "type": "team", "leaderId": "emp-b", "displayOrder": 1},
|
||||
{"id": "team-b", "parentId": "org-root", "name": "가상 운영팀", "type": "team", "leaderId": null, "displayOrder": 2}
|
||||
],
|
||||
"employees": [
|
||||
{"id": "emp-a", "organizationId": "org-root", "affiliationId": "aff-a", "name": "가상직원 가", "position": "대표", "jobTitle": "본부장", "profileImage": null, "workStatus": "active"},
|
||||
{"id": "emp-b", "organizationId": "team-a", "affiliationId": "aff-a", "name": "가상직원 나", "position": "책임", "jobTitle": "팀장", "profileImage": null, "workStatus": "active"},
|
||||
{"id": "emp-c", "organizationId": "team-a", "affiliationId": "aff-b", "name": "가상직원 다", "position": "선임", "jobTitle": null, "profileImage": null, "workStatus": "dispatched"},
|
||||
{"id": "emp-d", "organizationId": "team-b", "affiliationId": "aff-b", "name": "가상직원 라", "position": "선임", "jobTitle": null, "profileImage": null, "workStatus": "onLeave"}
|
||||
],
|
||||
"affiliations": [{"id": "aff-a", "name": "가상 관계사 A"}, {"id": "aff-b", "name": "가상 관계사 B"}],
|
||||
"relations": [{"id": "rel-a", "sourceOrganizationId": "team-a", "targetOrganizationId": "team-b", "type": "cooperation"}],
|
||||
"presentation": {
|
||||
"title": "가상 조직도", "description": "설계 검토용 가상 데이터", "hubText": "협업 중심",
|
||||
"groups": {"showEmployees": true, "showSummary": true, "showDispatched": true, "showOnLeave": true, "showAffiliations": true},
|
||||
"organizationOrder": ["org-root", "team-a", "team-b"]
|
||||
},
|
||||
"metadata": {"schemaVersion": "0.1-draft", "asOfDate": "2026-10-12", "dataVersion": "mock-1", "updatedAt": null, "updateNote": "초안 예시"}
|
||||
}
|
||||
```
|
||||
|
||||
직원 고유 ID 수 4, 상태별 raw 수 2/1/1은 fixture 산술 기준이다. 업무상 재직/전체 인원과 파견·휴직 포함 여부는 §5의 정책 승인이 필요하다. 조직장 카드와 상태 그룹에 같은 직원이 보이더라도 raw ID 수가 늘어나지 않아야 한다.
|
||||
|
||||
## 4. Validation과 오류 상태
|
||||
|
||||
제안 순서: 구조·버전 → 각 collection ID index → parent/직원/관계사/leader 참조 → cycle/root → 업무 규칙·입력 안전성. invalid 데이터를 engine로 전달하지 않는다.
|
||||
|
||||
- `SCHEMA_VERSION`: 승인된 버전과 불일치. 자동 추정·silent migration 금지.
|
||||
- `DUPLICATE_ID`: 같은 collection 내 ID 중복. 임의 last-write-wins 금지.
|
||||
- `REQUIRED_VALUE`: 누락/null 위반/공백뿐인 필수 값/잘못된 타입.
|
||||
- `ORGANIZATION_REFERENCE`: 직원 소속이 존재하지 않음. 삭제 시 parent/직원 이동을 일관된 스냅샷으로 제공해야 한다.
|
||||
- `PARENT_REFERENCE` / `HIERARCHY_CYCLE`: 없는 parent, 자기 parent, 다단계 순환. 정렬로 cycle을 은폐하지 않는다.
|
||||
- `LEADER_REFERENCE`: leader 직원이 없음. leader가 동일 조직/하위 조직/타 조직 소속이어도 되는지는 TBD이며 정책 없는 불일치는 승인 요구 issue로 보고한다.
|
||||
- `AFFILIATION_REFERENCE`: 직원 관계사 없음. 미지정 소속 허용 정책은 TBD.
|
||||
- `INVALID_STATUS` / `INVALID_TYPE`: 허용 vocabulary 외 값. 제안 enum이 실제 승인 목록을 대신하지 않는다.
|
||||
- `RELATION_REFERENCE` / `INVALID_RELATION`: 없는 양 끝 조직, 자기 관계, 중복 양 끝+유형, 허용되지 않은 유형을 검사한다. 관계 방향·대칭·역방향 중복 허용은 TBD. 일반 관계의 cycle은 조직 parent cycle과 구별한다.
|
||||
- `PRESENTATION_REFERENCE`: organizationOrder의 없는 ID·중복·정렬 정책 위반.
|
||||
- `UNSAFE_INPUT`: 실행 가능한 HTML/스크립트 payload·위험한 이미지 URL·제어문자/식별 교란 입력을 검사한다. 표시 문자열은 plain text, external `className/style/nodeTemplate`는 받지 않는다. image scheme/origin·상대 경로 정책·길이 상한은 승인 후 확정한다. JSON Schema `uri-reference`만으로 URL 안전성을 보장하지 않는다.
|
||||
|
||||
입력 검사와 출력 안전성은 함께 적용한다. 정상 문자열을 무조건 삭제하지 않고 거부/경고 기준을 문서화한다. DOM 텍스트는 `textContent`로 출력하고 engine 템플릿에는 외부 문자열을 직접 삽입하지 않는다. engine ID는 내부 안전 ID로 변환한다. HTML escaping만으로 이미지 URL 검사를 대체하지 않는다.
|
||||
|
||||
구조·참조·cycle·보안 실패는 Error 상태로 전환하고 engine mount/update를 중단한다. 사용자 메시지는 해당 데이터가 표시 불가능함을 설명하되 원본 개인정보를 노출하지 않는다. partial 데이터 자동 제거는 별도 승인 전 하지 않는다. 직원 0명과 조직 0개의 Empty 정의, 갱신 실패 시 직전 유효 화면 유지 여부는 TBD다. 미확정 집계 정책은 차트 유효성과 분리하여 집계를 `unavailable`로 표시하는 방식을 제안한다.
|
||||
|
||||
fatal Validation issue는 차트 Error로 처리한다. 집계 정책 미확정은 fatal data issue가 아니며 유효한 차트의 ready와 별도 AggregationResult `unavailable`로 공존하는 안을 제안한다. Source의 HTTP/JSON/매핑 실패는 dataSource 위치의 별도 code로 구별한다. warning·업무 승인 필요 issue의 치명도와 공개 반환 형식은 승인 전 TBD다. 어떤 경로도 원본 서버 오류·개인정보를 callback에 그대로 전달하지 않는다.
|
||||
|
||||
Validation·집계·render는 같은 불변 스냅샷을 사용하며 engine는 별도 복사본만 받는다. Source가 여러 API/페이지를 조합할 때의 시간 일관성은 ID 참조 검사와 별도로 보장해야 한다. Source 캡처·revision 반영 계약은 Architecture/Framework Integration을 따른다.
|
||||
|
||||
## 5. 인원 집계와 미확정 정책
|
||||
|
||||
원본 `employees`에서 계산하며 DOM 카드·engine 노드 수를 세지 않는다. Validation 통과 직원 ID 집합을 사용하고 parent 트리의 하위 조직을 통해 집계한다. 추가 관계·허브·접힌/숨겨진 카드·표시 설정은 조직 소속을 변경하지 않는다.
|
||||
|
||||
- 조직 직접 인원: 해당 organizationId의 직원 집합.
|
||||
- 하위 포함 인원: 자기 조직 + 승인된 parent 트리 하위 조직의 직원 ID 합집합.
|
||||
- 직위별 인원: 정해진 대상 집합을 `position`으로 그룹화. 직책과 혼합하지 않는다.
|
||||
- 재직/파견/휴직: 승인된 상태 매핑과 포함 정책을 적용한다. raw 상태 카운트와 재직 정의는 구별한다.
|
||||
- 관계사별 인원: `affiliationId`로 그룹화하고 동일한 승인 정책을 적용한다.
|
||||
- 전체 인원: 승인된 전체 대상의 직원 ID 집합. 하위 포함 조직별 숫자를 더하면 중복되므로 그 합을 총원으로 쓰지 않는다.
|
||||
|
||||
집계 정책은 PresentationConfig와 별도로 제공하는 제안이다. 아래 type의 값이나 default는 아직 승인되지 않았다.
|
||||
|
||||
```ts
|
||||
interface ApprovedAggregationPolicy {
|
||||
policyVersion: string;
|
||||
includedStatuses: WorkStatus[];
|
||||
activeStatuses: WorkStatus[];
|
||||
includeLeaders: boolean;
|
||||
membershipMode: "primaryOnly" | "uniqueEmployee";
|
||||
}
|
||||
type AggregationResult =
|
||||
| { status: "unavailable"; reason: "policyRequired" }
|
||||
| { status: "ready"; policyVersion: string; total: number };
|
||||
```
|
||||
|
||||
TBD: 파견·휴직을 재직/전체/팀/관계사에 포함하는지, 조직장 포함·겸직 조직장 배분, 조직 간 중복 소속, 파견 원소속/파견지 기준, 퇴사·예정 상태 필요 여부, 관계사 합과 총원 관계. 초안의 단일 `organizationId`는 복수 소속을 표현하지 못한다. 복수 소속이 필요하면 별도 Membership 계약과 schema revision을 승인하고 집계 mode를 확정한다. `uniqueEmployee` 후보만으로 현재 복수 소속이 지원된다고 주장하지 않는다.
|
||||
|
||||
정책 없이 임의 총원을 표시하지 않는다. 향후 테스트에는 leader 중복 표시, 상태 변경, 조직 이동, 하위 포함 중복 제거, 관계사 이동, 표시 숨김, 동일 JSON의 Vanilla/React 집계 일치를 포함한다. 같은 조건에 같은 결과가 나와야 한다.
|
||||
|
||||
집계 정책 자체도 policyVersion·지원 membershipMode·허용 status·중복 status·업무상 상호 관계를 검증한다. `activeStatuses`와 `includedStatuses`의 관계, leader 제외가 어느 조직의 어떤 직원에 적용되는지는 승인 전 TBD다. 필드 값만 받아 임의 해석하지 않는다. 환경별 정렬 일치를 위해 ID 비교 기준·locale 의존 정렬 여부도 확정한다.
|
||||
@@ -0,0 +1,179 @@
|
||||
# OrgChart Framework Integration
|
||||
|
||||
상태: 설계 계약·사용 예시 제안. **현재 package/Wrapper/HTML bundle은 존재하지 않으며 예시 API·경로도 승인 전이다.** 이 문서는 코드를 생성하거나 지원 완료를 주장하지 않는다. [Architecture](architecture.md)의 facade와 [Data Contract](data-contract.md)를 공통 기준으로 한다.
|
||||
|
||||
## 1. 지원 범위와 책임
|
||||
|
||||
1차 공식 개발 대상은 **Vanilla HTML/JavaScript와 React만**이다. 현재 두 환경 모두 구현 미착수·실행 **NOT VERIFIED**다. Vue·Angular·기타 framework는 이번 개발 범위에서 제외하며 Adapter 구현·배포·지원 검증을 계획에 포함하지 않는다. 최초 추가 요청의 Vue 설계 항목은 최신 지원 범위 확정으로 대체했다. 향후 Core facade를 연결할 수 있도록 framework 독립성만 유지한다.
|
||||
|
||||
우선 검토 구성은 `@hm-design/org-chart`(Core), `@hm-design/org-chart-react`(React Wrapper)다. 실제 경로·두 패키지 분리는 workspace/build/dependency 검토 후 최종 승인한다. React Wrapper는 Engine Adapter와 다르다: Wrapper는 React와 Core 사이, Engine Adapter는 Core와 dabeng 사이에 있다. Data Source Adapter는 외부 형식과 normalized 계약 사이에 있다.
|
||||
|
||||
- Core: 표준 DOM, 공통 계약·Validation·ViewModel·집계·engine·노드 UI·Theme CSS·인터랙션·요청 취소·생명주기.
|
||||
- Vanilla 호스트: container 제공, Source·options 전달, Theme/CSS/script 로딩, 페이지 제거 시 Destroy.
|
||||
- React Wrapper: ref/effect로 Core mount, Props 변경을 update/reload 또는 설정 재생성에 연결, 언마운트 시 Destroy. 공통 업무 로직·렌더링 엔진·노드 디자인을 재구현하지 않는다.
|
||||
- 상위 앱: Layout·Navigation·Routing·API 인증·권한·Theme 공급. Core에 React context나 인증 hook을 요구하지 않는다.
|
||||
|
||||
두 환경은 동일 JSON 입력 → 동일 Validation → 동일 계층·집계 → 동일 Core 노드·Theme·인터랙션을 사용한다. React 외부 HM Component는 페이지 조합이며 Core 내부 노드의 다른 디자인을 만드는 근거가 아니다.
|
||||
|
||||
## 2. 생명주기 계약 초안
|
||||
|
||||
메서드 이름은 확정 생명주기 요구를 구체화한 API 제안이다. engine 자체의 destroy 지원과 혼동하지 않는다.
|
||||
|
||||
1. `createOrgChart(options)`: DOM 생성·요청 없이 Handle을 만든다. framework import와 전역 window 접근 부작용을 ESM import 시 피하는 안을 검토한다.
|
||||
2. `mount(container)`: 연결된 HTMLElement의 전용 자식 root를 소유하고 첫 Source를 읽는다. 호스트의 sibling/기존 콘텐츠를 지우지 않는다. 한 Handle에 한 container이며 중복 mount는 명시적 호출 오류로 거부한다.
|
||||
3. `update(input)`: mounted 상태에서 normalized 스냅샷 후보를 검증·집계·렌더한다. Source fetch 없이 스냅샷만 바꾼다. 현재 Source 객체는 유지하므로 이후 reload는 그 Source를 다시 읽는다.
|
||||
4. `reload(source?)`: mounted 상태에서 현재/교체 Source를 읽고 같은 반영 경로를 사용한다. JSON → Mock API/실제 API 전환의 공개 지점이다.
|
||||
5. `destroy()`: 동기·반복 안전한 최종 정리. 진행 요청 abort·revision 무효화·콜백 중단, Core/engine 이벤트·Observer·frame/timer·DOM·인스턴스 참조를 정리한다. destroyed Handle은 재사용하지 않고 새 Handle을 만든다.
|
||||
|
||||
mount/update/reload는 Promise<void> 제안이다. 데이터·Source 실패는 Error state로 알리고 Promise를 정상 완료하는 방식을 우선 검토한다. 잘못된 호출 순서·사용법은 명시적 오류로 reject한다. abort/새 revision에 의해 폐기된 작업은 unhandled rejection 없이 종료하고 DOM/상태를 바꾸지 않는다. mount 진행 중에도 이미 확보한 container의 update/reload는 이전 작업을 무효화한다. mount 전·destroy 후 update/reload는 오류다. Promise 완료는 해당 revision의 반영 또는 controlled error 전환까지이며 이미지/폰트의 모든 로드 완료를 뜻하지 않는다.
|
||||
|
||||
상태 전이는 loading → ready/empty/error다. update는 동기 validation 중에도 같은 state 의미를 유지한다. Empty 정의와 갱신 중/실패 시 직전 화면 유지 정책은 TBD다. 같은 Metadata 버전이라도 새 입력을 무시하지 않는다. reload/update 경쟁은 마지막 시작 revision이 우선하도록 제안한다.
|
||||
|
||||
엔진 정리 가능성은 최초 PoC에서 입증해야 한다. document 이벤트·관찰자를 해제할 경로를 찾지 못하면 Destroy 완료를 PASS로 표시하지 않는다. core가 소유하지 않은 engine 자원을 `innerHTML = ""`만으로 정리했다고 판단하지 않는다. 제한/통합 대안을 승인받기 전 기능 개발을 확대하지 않는다.
|
||||
|
||||
engine 재생성 전 이전 chart가 살아 있는 동안 기존 pan/zoom·minimap 등의 해제 가능성을 확인한다. update/reload 반복 후에도 이벤트/Observer가 증가하지 않는지 검사한다. Core와 데이터 계층의 DOM 없는 import는 upstream 직접 import와 구별하며 지연 engine 로딩/변환 entry를 검증한다.
|
||||
|
||||
## 3. 데이터 Source 사용 예시
|
||||
|
||||
아래 `createJSONSource`·`createExternalJSONSource`도 제안 API다. 모든 반환값은 같은 validator를 통과한다. 가상 데이터 `data`는 Data Contract §3 스냅샷을 뜻한다.
|
||||
|
||||
```ts
|
||||
// 제안 API; 아직 설치 가능한 공개 함수가 아니다.
|
||||
const jsonSource = createJSONSource(data);
|
||||
const externalSource = createExternalJSONSource({ url: configuredJsonUrl });
|
||||
// URL은 호스트 설정. Core에 회사 서버 주소를 하드코딩하지 않는다.
|
||||
```
|
||||
|
||||
JSON Mock은 객체 스냅샷을 공급하고 consumer 입력을 수정하지 않는다. External JSON은 AbortSignal·HTTP 오류·JSON 구문 오류를 처리한다. source가 JSON을 반환했다는 이유만으로 Validation을 생략하지 않는다.
|
||||
|
||||
JSON Source는 생성 시점 입력을 캡처하는 안, update는 호출 시점 입력을 확보하는 안을 제안한다. 같은 객체의 외부 mutation을 자동 변경 알림으로 취급하지 않는다. 검증·집계·DOM 반영은 Core 소유 불변 스냅샷에서 수행하고 최종 state/DOM commit 직전 revision·destroy 상태를 다시 확인한다.
|
||||
|
||||
API가 마련됐을 때의 공통 계약 예시:
|
||||
|
||||
```ts
|
||||
// OrgChartDataSource, OrgChartData는 문서의 초안 type이다.
|
||||
function createApiSource(
|
||||
request: (signal: AbortSignal) => Promise<unknown>,
|
||||
mapResponse: (raw: unknown) => OrgChartData
|
||||
): OrgChartDataSource {
|
||||
return {
|
||||
async load({ signal }) {
|
||||
return mapResponse(await request(signal));
|
||||
}
|
||||
};
|
||||
}
|
||||
const apiSource = createApiSource(hostApiRequest, mapApiResponse);
|
||||
await handle.reload(apiSource); // Core/노드 UI/집계/React Wrapper는 유지
|
||||
```
|
||||
|
||||
실제 request·mapping은 명세 확정 후 호스트에서 작성한다. mapResponse type 선언은 보안 경계가 아니며 Core가 결과를 재검증한다. Endpoint·인증·재시도·캐시를 임의로 예시에 넣지 않는다. Mock API는 같은 fixture를 Promise로 반환하고 지연/오류를 주입하여 실제 서버 없이 전환을 검증한다.
|
||||
|
||||
## 4. Vanilla HTML 사용 예시
|
||||
|
||||
아래 asset 이름·`window.HMOrgChart` namespace는 미래 browser bundle **제안**이며 파일을 제작한 것이 아니다. React 설치 없이 classic script + 별도 CSS + inline JSON으로 `file://` 직접 실행을 검증할 예시다. 포장된 Theme CSS·font·Core CSS·번들이 모두 상대 경로로 제공돼야 한다.
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>가상 조직도 검증</title>
|
||||
<link rel="stylesheet" href="./assets/hm-theme.css">
|
||||
<link rel="stylesheet" href="./assets/orgchart.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="orgchart-host"></div>
|
||||
<script id="orgchart-data" type="application/json">
|
||||
{
|
||||
"organizations": [{"id":"org-a","parentId":null,"name":"가상 조직","type":"root","leaderId":"emp-a","displayOrder":0}],
|
||||
"employees": [{"id":"emp-a","organizationId":"org-a","affiliationId":"aff-a","name":"가상직원 가","position":"책임","jobTitle":"조직장","profileImage":null,"workStatus":"active"}],
|
||||
"affiliations": [{"id":"aff-a","name":"가상 관계사"}],
|
||||
"relations": [],
|
||||
"presentation": {"title":"가상 조직도","description":null,"hubText":null,"groups":{"showEmployees":true,"showSummary":false,"showDispatched":false,"showOnLeave":false,"showAffiliations":false},"organizationOrder":[]},
|
||||
"metadata": {"schemaVersion":"0.1-draft","asOfDate":"2026-10-12","dataVersion":"html-mock-1","updatedAt":null,"updateNote":null}
|
||||
}
|
||||
</script>
|
||||
<script src="./assets/orgchart.browser.js"></script>
|
||||
<script>
|
||||
const data = JSON.parse(document.getElementById("orgchart-data").textContent);
|
||||
const { createOrgChart, createJSONSource } = window.HMOrgChart;
|
||||
const handle = createOrgChart({ dataSource: createJSONSource(data) });
|
||||
handle.mount(document.getElementById("orgchart-host"))
|
||||
.catch(() => console.error("OrgChart lifecycle error"));
|
||||
window.addEventListener("pagehide", () => handle.destroy(), { once: true });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
위 예시는 실행 검증 전 설계용이다. inline JSON은 가상 데이터만 사용한다. pagehide 이후 bfcache 복귀가 필요한 앱은 새 instance의 재mount를 호스트에서 계획한다. SPA 콘텐츠 제거에서는 pagehide에만 의존하지 않고 해당 제거 시점에 Destroy한다.
|
||||
|
||||
ESM 비교 예시(HTTP(S) static server에서 우선 검증):
|
||||
|
||||
```js
|
||||
import { createOrgChart, createJSONSource } from "./assets/orgchart.esm.js";
|
||||
const handle = createOrgChart({ dataSource: createJSONSource(data) });
|
||||
await handle.mount(document.getElementById("orgchart-host"));
|
||||
```
|
||||
|
||||
bare npm specifier는 일반 HTML에서 자동 해석되지 않는다. file://의 module/fetch 제한과 서버 기반 ESM 실행을 구별한다. `file://`에서는 inline JSON + classic bundle 경로를 검증하고 외부 JSON fetch가 가능하다고 보장하지 않는다. External JSON/API는 HTTP(S)·CORS·호스트 권한 조건에서 별도로 검증한다.
|
||||
|
||||
## 5. React 사용 예시와 Wrapper 계약
|
||||
|
||||
아래 package names·Component/Prop은 우선 분리안의 예시이며 실제 Export가 아니다. 동일 Core CSS·Theme를 사용한다. React 호스트의 기존 CSS entry 순서를 지키고 HM 공개 Component를 사용하는 주변 영역은 해당 HM Tailwind source/Theme 규칙을 유지한다.
|
||||
|
||||
```tsx
|
||||
import { useMemo } from "react";
|
||||
import { createJSONSource } from "@hm-design/org-chart";
|
||||
import { OrgChart } from "@hm-design/org-chart-react";
|
||||
import "@hm-design/org-chart/style.css";
|
||||
// 기존 앱 Theme entry에서 @hm-design/ui/theme.css를 연결한다.
|
||||
|
||||
function OrgChartContent({ data }: { data: OrgChartData }) {
|
||||
const dataSource = useMemo(() => createJSONSource(data), [data]);
|
||||
return <OrgChart dataSource={dataSource} />;
|
||||
}
|
||||
```
|
||||
|
||||
Props 후보는 dataSource·aggregationPolicy·interaction·onStateChange다. title/description/group/order는 공통 데이터의 PresentationConfig를 사용한다. React 전용 표현·집계 옵션을 따로 만들지 않는다. 직접 스냅샷 Prop 추가는 public API 승인 후 검토한다.
|
||||
|
||||
Wrapper 동작: 최초 ref container 확보 → Core 생성·mount; dataSource 교체 → 기존 Handle.reload(newSource); 동일 Source의 내부 값 변경은 명시적 reload 또는 새 immutable Source로 전달한다. 새 객체를 매 렌더 생성해 불필요한 reload를 유발하지 않는다. options 중 집계·인터랙션 변경은 Core의 갱신 API 확장 또는 cleanup 후 instance 재생성 중 PoC로 결정한다. 미확정 옵션을 조용히 무시하지 않는다.
|
||||
|
||||
언마운트는 destroy; effect cleanup→setup이 반복되는 React StrictMode에서도 새 instance만 mount한다. promise 경쟁·늦은 callback·변경되지 않은 props에 의한 중복 engine 생성·callback identity로 인한 무한 reload를 검사한다. Core가 DOM을 소유하는 전용 container 자식에 React가 동시에 노드를 render하지 않는다. Framework wrapper는 engine/node/Validation/집계 CSS를 복제하지 않는다.
|
||||
|
||||
SSR/Server Components의 DOM 렌더링은 이번 지원 완료 범위가 아니다. import 시 브라우저 global 부작용·React client boundary·hydrate 안전성은 검토하고 미확인 기능은 NOT VERIFIED로 남긴다.
|
||||
|
||||
## 6. CSS·Theme·배포 방식
|
||||
|
||||
같은 Core CSS와 HM Variables mapping을 모든 배포가 사용한다. 호스트는 Theme를 로드하고 Core는 컨테이너 내부 전용 selector만 사용한다. Theme 정책과 실제 선언 근거는 [Integration §3](integration.md#3-hm-theme-연결)을 따른다.
|
||||
|
||||
- npm: Core ESM + type + CSS, Wrapper ESM + type 후보. exports/types/files/sideEffects를 설계하고 package tarball 안에서 실제 import·CSS·font/asset 존재를 검증한다. 실제 publish는 별도 승인이다.
|
||||
- ESM: npm bundler import와 브라우저 상대 URL import를 구별한다. Core에 React/React DOM 또는 unresolved bare dependency가 섞이지 않도록 검증한다.
|
||||
- browser bundle: IIFE/UMD 중 직접 script 사용 형식을 선택한다. `window.HMOrgChart` 후보, 엔진 포함 여부·외부 dependency·license·크기·CSP를 검토한다. inline page 예시는 CSP 승인 완료를 뜻하지 않는다.
|
||||
- CSS: Core 스타일을 별도 `style.css` export/상대 asset로 제공하고 Theme를 중복 주입하지 않는다. upstream CSS의 container 밖 누출도 포함해 scope를 검증한다.
|
||||
- TypeScript: Core 선언에는 React type이 없어야 하고 Wrapper만 React Props를 가진다. JS consumer에 TS transpilation을 요구하지 않는다.
|
||||
- Theme 미연결: 승인된 scope 기본 스타일 제공 vs Theme 필수 연결 중 **TBD**다. 어느 정책이든 HTML/React가 같아야 한다. approved 기본값 없이 fallback 팔레트를 새로 만들지 않는다.
|
||||
|
||||
Root의 기존 build script는 두 패키지만 처리하므로 새 Core·Wrapper build 연결 범위는 승인 후 확정한다. 엔진 CommonJS/UMD를 ESM·browser bundle로 변환할 수 있는지, export 기능 dependencies가 필수로 포함되는지, DOM-only engine와 adapter의 tree-shaking 가능성은 **NOT VERIFIED**다. 단순히 빌드 설정을 제안했다는 이유로 배포 가능 PASS를 내지 않는다.
|
||||
|
||||
Vanilla Theme를 위해 Core dependency/peer에 React peer를 가진 `@hm-design/ui` 전체 package를 필수로 추가하지 않는다. 승인된 호스트 asset/별도 Theme 산출 경로에서 React 미설치 consumer 동작을 확인한다. Core/engine CSS 누출, 호스트 Theme의 전역 영향, 전달된 font의 상대 URL은 서로 다른 검증 대상이다.
|
||||
|
||||
Theme owner는 호스트 하나이며 Core는 scope를 상속한다. 별도 Scoped Theme는 기존 `[data-hm-theme]` mapping 재계산·Scoped partial Default 의미를 따르고 Core가 자동 주입/reset하지 않는다. Global A/Scoped B의 두 instance가 서로 영향을 주지 않는지 확인한다.
|
||||
|
||||
## 7. 최소 통합 검증 계획
|
||||
|
||||
동일 가상 fixture·공통 설정·Theme로 두 환경을 비교한다. 테스트가 확인해야 할 차이를 명시하고 같은 검사 반복을 피한다.
|
||||
|
||||
1. React 설치 없는 HTML 단독 페이지에서 표시. `file://` classic bundle과 HTTP(S) ESM의 asset/console 결과를 각각 기록.
|
||||
2. React에서 같은 Core로 동일 계층·노드 UI·인터랙션 표시. 새 렌더링 엔진이 없는지 dependency/code 검토.
|
||||
3. 같은 JSON의 조직 ID·parent·순서·leader·직원 그룹과 공통 집계 결과 일치.
|
||||
4. 조직 추가/삭제/이동·직원 이동/상태/관계사/사진·title/기준일 변경 후 재검증과 갱신. consumer 원본 객체를 바꿔도 이미 캡처/반영한 스냅샷과 집계가 암묵적으로 바뀌지 않음.
|
||||
5. JSON → Mock API Source → JSON 전환, API 매핑 계약·loading/error·응답 경쟁·abort 확인.
|
||||
6. HM Theme 연결 및 승인된 Theme 미연결 정책. computed Variables·node CSS·font·scope가 두 환경에서 일치. Global A + Scoped B + 두 instance의 독립성·Theme owner와 cleanup도 확인.
|
||||
7. mount/update/reload/destroy·연속 remount·StrictMode·다중 instance에서 이벤트/Observer/timer/DOM 잔존과 늦은 callback 확인.
|
||||
8. CSS 전역 충돌과 390/768/1280/1440px Overflow·키보드·focus·읽기 가능성 검증.
|
||||
9. Core build·npm/ESM/browser bundle·CSS/type/license와 React dependency 미포함 검증.
|
||||
10. 공식 운영 main·기존 UI/Theme/Storybook과 무관 파일 보존 증거. 코드 중복·공통 Core 재사용 검토.
|
||||
|
||||
Vue/Angular 실행 검증은 여기 포함하지 않는다. 기술 제약·승인 게이트는 [Implementation Plan](implementation-plan.md)을 따른다.
|
||||
@@ -0,0 +1,110 @@
|
||||
# OrgChart Implementation Plan
|
||||
|
||||
상태: 설계 검토 후 수행할 개발 계획. **이번 작업은 Markdown만 작성하며 구현을 시작하지 않는다.** 1차 공식 개발 대상은 Vanilla HTML/JavaScript와 React다. Vue·Angular·기타 framework는 제외한다. [README](README.md)의 요구사항 추적과 [Framework Integration](framework-integration.md)의 통합 검증을 따른다.
|
||||
|
||||
## 1. 단계별 계획과 완료 조건
|
||||
|
||||
### 1단계 — 설계 승인·핵심 기술 PoC
|
||||
|
||||
사용자가 이 문서를 검토한 뒤 후속 개발 권한을 확인한다. Core/React Wrapper 두 패키지 분리안과 실제 workspace/build 연결 범위를 결정한다. engine 배포 버전을 고정하고 native DOM·module import·custom node·완전 cleanup·CSS scope를 소규모 PoC로 확인한다. 캡처가 제공되면 콘텐츠 8영역·허브·곡선·카드 배치를 대조한다.
|
||||
|
||||
완료 조건: 정확한 engine version/tag/배포 artifact·license와 dependency 범위가 기록됨; React 없는 HTML 구동 가능성, Destroy·다중 instance·custom node 가능성 근거 확보; node Theme mapping·미연결 정책·모바일 선택의 승인 필요 범위가 구분됨. 완전 정리가 불가능하면 기능 구현을 확대하지 않고 차단 사항·대안을 보고한다. 이 기술 PoC는 뒤 단계의 전제라 필요한 최소 중간 검증에 해당한다.
|
||||
|
||||
### 2단계 — 공통 데이터 계약·Validation·집계
|
||||
|
||||
Schema 초안의 필드/enum/null/version/root/leader·복수 소속과 집계 정책을 승인하고 순수 JS/TS 데이터 계층을 구현한다. JSON Mock/외부 JSON Source와 공통 validator를 만든다. API 명세 없이 Mock API Source로 교체 계약을 확인한다.
|
||||
|
||||
완료 조건: 원본 ID 모델·동일 Source Interface; 중복 ID/참조/cycle/상태/관계/안전 입력 실패가 controlled issue로 변환됨; 승인된 집계 정책으로 단일 ID·하위 포함·상태/관계사 이동이 결정적 결과를 생성함. 미승인 정책은 code default로 숨기지 않음. UI·framework 의존 없이 사용할 수 있음.
|
||||
|
||||
### 3단계 — Vanilla Core와 콘텐츠
|
||||
|
||||
승인한 패키지/build 구조에서 engine adapter, DOM 노드 UI, 제목·허브·summary·상태/관계사 영역을 구현한다. mount/update/reload/destroy, Source 경쟁·취소·instance scope·Core CSS·Theme 연결을 구현한다. Overlay/Layout은 PoC와 승인한 디자인만 구현한다.
|
||||
|
||||
완료 조건: Core에 React import 없음; API endpoint/Navigation/로그인 의존 없음; 데이터만 변경해 조직·직원·relation·제목 갱신; 승인 정책의 집계; 요청된 인터랙션·사용자 상태·정리 보장; HTML 단독 배포 예시의 runtime 목표 명확. 임의 추가 검색/Export/메뉴/drag 편집은 없음.
|
||||
|
||||
### 4단계 — React Wrapper와 콘텐츠 Storybook
|
||||
|
||||
Wrapper는 Core 생성·ref·props 전달·Source 변경·언마운트 cleanup만 연결한다. 동일 Core UI와 CSS를 사용하고 Storybook은 콘텐츠 단독으로 구성한다. 기존 Component/Pattern/Theme/대표 Story는 보존한다. 현 [Storybook main](../../.storybook/main.ts)의 glob은 `src/**/*.stories.ts(x)`이며 패키지 내부 Story는 자동 포함되지 않는다. `src/org-chart/*.stories.tsx`는 제안 위치다.
|
||||
|
||||
완료 조건: engine/Validation/집계/노드 UI 중복 없음; 데이터 변경·StrictMode·unmount 시 동일 Core 계약 준수; 모든 Story는 가상 데이터만 사용; Header/Dashboard/신규 페이지 조합 없음. 지원 Props와 stale response 동작을 명시함.
|
||||
|
||||
### 5단계 — 배포·최종 통합 검증
|
||||
|
||||
npm/ESM/browser bundle·CSS·`.d.ts`와 license·asset·dependency 포함 범위를 실제 build와 consumer에서 확인한다. §3의 최종 검증을 변경 최종 상태 기준으로 한 번 종합 실행한다. 실패를 수정한 경우 영향받은 검사만 다시 수행한다. 같은 조건으로 이미 성공한 검사를 형식적으로 반복하지 않는다.
|
||||
|
||||
완료 조건: HTML과 React의 공통 기능/디자인/데이터/집계/인터랙션 일치; 네 컨테이너 너비의 실행 증거; JSON → Mock API 교체; lifecycle/CSS/접근성·asset 결과; build와 browser 결과 별도 보고; NOT VERIFIED·TBD를 감추지 않음; 운영 main 및 요청 밖 파일 보존. 실제 API 지원 완료와 npm publish는 이 단계에서 자동 주장하거나 수행하지 않는다.
|
||||
|
||||
## 2. Storybook 구성 제안
|
||||
|
||||
최상위 `OrgChart` 콘텐츠 그룹 후보다. 이름·기본 sidebar 대표 Story와 세부 검증 Story 배치는 승인 후 확정한다. 기존 All Story나 navigation Pattern 대표 Story를 교체하지 않는다.
|
||||
|
||||
- Basic OrgChart: 작은 가상 ID 계층, 기본 노드.
|
||||
- Leader Hierarchy: 최상위/하위 leader, 공석, 허브 처리.
|
||||
- Organization / Team / Cell: 부문·실·Div·셀·팀, 정렬·다단계 계층.
|
||||
- Employee Cards: 이름·직위/직책·이미지 실패/fallback·긴 텍스트.
|
||||
- Summary Table: 직접/하위 포함·직위/상태 집계, 정책 필요 상태.
|
||||
- Multiple Affiliations: 같은 집계 정책의 관계사별 숫자와 총원.
|
||||
- Dispatched / On Leave: 상태 변경, 원소속 표시 정책, 중복 카드와 ID 집계 구별.
|
||||
- Full OrgChart Content: 8개 콘텐츠 영역과 추가 관계. Header·GNB/LNB·Dashboard 전체 조합 제외.
|
||||
- JSON Data Update: 조직/직원 이동·추가/삭제·상태/사진·명칭·기준일 갱신.
|
||||
- Mock API Data Source: 같은 fixture의 지연/실패/응답 경쟁, JSON Source 전환.
|
||||
- Loading / Empty / Error: schema/참조/cycle/정책 오류·제어된 상태.
|
||||
- Responsive Container: 390/768/1280/1440px와 부모 크기 변경, 다중 instance.
|
||||
|
||||
Storybook은 React Wrapper의 실제 Core를 검증한다. Vanilla 단독 consumer 검증을 Storybook 성공으로 대체하지 않는다. 지원하지 않는 API·인증·직원 개인정보나 verification Story의 부가 기능을 신규 요구로 복사하지 않는다.
|
||||
|
||||
## 3. 최종 통합 검증
|
||||
|
||||
[Framework Integration §7](framework-integration.md#7-최소-통합-검증-계획)의 10항목을 최종 증거의 공통 기준으로 사용한다. 다음을 관련 항목에 연결한다.
|
||||
|
||||
- 데이터: 모든 Validation 실패 유형·leader/하위 포함/상태/관계사 집계·immutable 원본·같은 입력의 결정적 결과·JSON/Mock API parity.
|
||||
- HTML/React: 동일 조직 ID/parent/순서/leader/직원 그룹·계산 숫자·노드 UI·Theme·키보드/zoom/확장 상태. Wrapper의 다른 engine 구현 부재와 Core 코드 재사용을 검토.
|
||||
- lifecycle: data update/reload 경쟁·abort·StrictMode·반복 mount/unmount·destroy 두 번·destroy 후 callback 없음·두 instance의 DOM ID/이벤트/scroll/focus 격리. 이벤트/Observer/DOM 수와 제거 후 동작 증거를 남김.
|
||||
- lifecycle 재생성: 이전 chart 참조를 잃기 전 handler/Observer 정리, 반복 init/update/reload의 누적 이벤트 검사. facade import와 engine의 DOM global 부작용을 구별하고 지연 로딩 entry를 확인.
|
||||
- 컨테이너: 네 너비의 캡처·computed style·document/내부 scrollWidth/clientWidth·scrollHeight/clientHeight, 긴 명칭·ID 한 줄 식별·이미지/폰트 로드·absolute 알림 기준·Overlay 좌표.
|
||||
- 접근성: 이름/label 연결, Tab/Enter/Space/Escape 등 해당 기능 키보드·focus 복귀, disabled/readOnly, screen reader 상태 설명. 실행하지 않은 범위는 NOT VERIFIED.
|
||||
- 배포: package tarball imports·browser-ready ESM/classic bundle·별도 CSS/타입/상대 asset·React 미설치 HTML·Theme 미연결 승인 정책·CSP/CORS 범위. typecheck/build/browser를 구분하고 통과한 조건만 PASS.
|
||||
- Theme 배포·소유권: React peer 없는 Vanilla Theme 공급 경로, Core/engine CSS와 호스트 Theme global 영향 구별, 상대 font URL, Global A/Scoped B 두 instance의 computed·Default/partial·cleanup. Wrapper의 별도 summary/state/node 구현 부재 확인.
|
||||
- 영향: 최종 Git diff·요구사항·관련 docs/Storybook 정합성·불필요한 변경·운영 main/기존 Component/Pattern/Theme 보존.
|
||||
|
||||
관련 TypeScript·build·Storybook 명령은 미래 승인된 package scripts로 결정한다. 현재 package의 `prebuild/pretypecheck`가 생성물을 만들 수 있어 문서 전용 작업에서 실행하지 않는다.
|
||||
|
||||
Guard는 React consumer target이 실제 존재할 때 [Guard 정책](../guidelines/ai-generation-guard.md)의 적합한 범위·files/result로 1회 적용한다. PARTIAL 콘텐츠 검사가 전체 페이지 계약을 대체하지 않는다. Vanilla Core는 React bootstrap 전제와 충돌할 수 있으므로 기존 [Runtime Portability](../guidelines/runtime-portability.md)를 바탕으로 검사 적용 범위를 별도 합의하고 자동 PASS를 만들지 않는다. Guard 원본 status/staticStatus와 Runtime 증거를 구별하고 STYLE의 STATIC/RUNTIME/DESIGN_INTENT/FINAL을 함께 보고한다. 현재 지침을 이번 문서 작업에서 고치지 않는다.
|
||||
|
||||
## 4. TBD 결정 목록과 리스크
|
||||
|
||||
- **패키지/배포**: 두 package 최종 분리·경로·exports·type·dependency/peer·build 연결·Core npm 이름·browser namespace/format·engine 고정 버전. vanilla용 bare-import 없는 bundle과 React dependency 격리가 필요하다.
|
||||
- **생명주기**: upstream 완전 disposal·Observer·document 이벤트 해제, 갱신 시 상태/focus/zoom 보존, 옵션 갱신 방식·대용량 전체 rebuild 성능.
|
||||
- **데이터**: schemaVersion·enum/null/필수 값·ID 범위·root 수·무소속·leader 소속/겸직·관계 방향/중복·복수 소속 계약·정렬 우선순위·안전 입력 상한.
|
||||
- **집계**: 파견/휴직 포함·재직 정의·조직장 포함·원소속/파견지·직원 ID 중복·관계사/총원 분모. 승인 없이는 숫자를 업무 기준으로 확정하지 않는다.
|
||||
- **디자인/모바일**: 캡처 자료 미제공, 허브 실체·곡선 Overlay·카드 anchor·노드/그룹 배치·읽기 가능한 최소 폭/배율·zoom limit·모바일 방식·Theme 미연결 정책. computed 일치와 디자인 승인도 구별한다.
|
||||
- **API/보안**: 실제 endpoint/auth/권한·캐시/재시도·갱신·버전·이미지 권한·개인정보·외부 입력/URL 정책. 명세 없는 부분은 상상해 구현하지 않는다.
|
||||
- **지침 정합성**: 기존 React/HM 재사용 원칙과 framework-independent 노드의 예외, 공식 일반 수정 경로와 Feature 문서 경로, Guard runtime 전제. README의 차이를 보존하고 작업별 사용자 지시를 우선한다.
|
||||
|
||||
Vue·Angular·기타 framework Adapter는 TBD 개발 범위가 아니라 **이번 범위 제외**다. 미래 별도 요청이 있어야 개발 대상으로 편입한다.
|
||||
|
||||
## 5. 이번 문서 작업의 검증 기록
|
||||
|
||||
기준 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c`, 경로 `HMDesign-OrgChart`, 브랜치 `feature/orgchart-package`. 최초/최신 추가 요청의 확정 요구를 README에서 추적한다.
|
||||
|
||||
- PASS: 작업 전 Git root/branch/HEAD/origin/운영 Worktree·원격 main/Feature SHA·깨끗한 Working Tree 확인.
|
||||
- PASS: 현재 workspace·package export·Storybook glob·CSS entry·선택 Component/Pattern·Token 실제 파일 확인. 이는 읽기 전용 구조 확인이며 신규 API/배포 검증은 아니다.
|
||||
- PASS: 문서 7개에 콘텐츠 독립성·Core/React Wrapper·같은 계약/집계/노드/Theme/인터랙션·Source 교체·페이지 제외·TBD·개발 게이트를 반영.
|
||||
- PASS: 기존 `node scripts/check-doc-links.mjs` 실행에서 Markdown 118개·내부 링크 620개·problems 0. 신규 OrgChart 문서의 내부 링크는 53개다. 외부 URL 도달성 전체 검사는 포함하지 않는다.
|
||||
- PASS: JSON code/inline 블록 3개 구문, JavaScript/HTML inline script 2개 구문, 가상 fixture의 required/type/enum/null·ID 참조·parent cycle·raw 수 2/1/1, Schema와 TypeScript 초안의 필드 일치를 읽기 전용으로 확인했다. Schema에 사용한 일부 keyword의 구조 검사이며 완전 Draft 2020-12 validator나 TypeScript typecheck가 아니다.
|
||||
- PASS: 최종 확인 시 개발 Worktree는 `docs/org-chart/*.md` 7개만 untracked, 기존 tracked/staged diff 0. 운영 Worktree는 깨끗하고 Local/원격 main·Feature SHA는 시작 HEAD 그대로였다. package/lockfile·기존 UI/Theme/Storybook/AGENTS 수정, 설치·새 Worktree·Stage·Commit·Push 없음.
|
||||
- NOT VERIFIED: 캡처 시각 대조, engine 고정 tag/npm 배포물 동일성, 실행/빌드/HTML bundle/React Wrapper/Theme computed/접근성·반응형·생명주기·실제 API. 구현 전 문서 작업이라 실행 검사 PASS를 내지 않는다.
|
||||
- NOT VERIFIED: 완전한 JSON Schema validation과 TypeScript compiler/typecheck. 현재 Worktree의 관련 dependency가 없어 설치하지 않았고 필드/구문 확인으로 지원 완료를 주장하지 않는다.
|
||||
- 적용 대상 없음: 이번 화면 생성 Guard(typecheck/build 포함). React bootstrap·UI 변경 없이 Markdown만 작성하므로 실행하지 않는다.
|
||||
|
||||
이 단계의 최종 산출물은 사용자 검토 대상 설계 문서다. 다음 기능 개발·설치·Stage·Commit·Push는 시작하지 않고 사용자 검토 및 승인을 기다린다.
|
||||
|
||||
## 6. 병렬 읽기 전용 검토 통합
|
||||
|
||||
사용자 요청으로 분석 작업자 3개를 동시에 사용했다. 각 작업자는 읽기 전용으로 검토했고 문서 작성·수정은 메인 작업자만 수행했다. 기존 링크/fixture 검사는 작업자마다 중복 실행하지 않았다.
|
||||
|
||||
- **엔진 HTML·React 호환성**: 이전 chart를 잃기 전 update/reload cleanup, 문자열 template + createNode 안전 DOM 우선안, DOM global import 부작용·지연 로딩, 우선 두 패키지 표현 통일을 Integration/Architecture/Framework Integration에 통합했다.
|
||||
- **HM Theme·Token·UI**: React peer 없는 Vanilla Theme 공급, host Theme와 Core/engine CSS 영향 구별, 상대 font, Scoped mapping/Default/owner, Wrapper의 summary/state/node 중복 금지를 Architecture/Integration/UI Composition/Framework Integration에 통합했다.
|
||||
- **데이터·Validation·API 확장**: fatal data issue와 집계 unavailable/Source 실패의 경계, immutable 스냅샷·최종 revision 확인, 여러 API/페이지의 완전한 동일 기준 스냅샷 조립, 집계 정책 자체 검사·결정적 정렬 조건을 Data Contract/Architecture/Integration/Framework Integration에 통합했다.
|
||||
|
||||
각 검토는 설계·공식 소스의 정적 근거다. 실제 이벤트 누수·빌드·computed·API 일관성이 검증됐다는 뜻이 아니며 해당 NOT VERIFIED를 유지한다. 중복 문서·구현 코드·별도 리뷰 파일은 만들지 않았다.
|
||||
@@ -0,0 +1,95 @@
|
||||
# HMDesign OrgChart P1 결과 — 2026-10-12
|
||||
|
||||
RESULT: **PARTIAL**. 최종 요청의 독립 디자인·개별 공개 API·5개 Story 메뉴·HTML/React 기능 검증은 완료했다. 원본 Guard의 정적 토큰 추적 FAIL, 실측 흰색 부서명 컬러의 일부 명도 대비 부족, Workdesk 픽셀 일치 미검증은 아래에 보존한다. 전체 디자인 승인 또는 배포 승인으로 해석하지 않는다.
|
||||
|
||||
## 개발 환경과 보존
|
||||
|
||||
- 실제 수정 경로: `D:\디자인시스템\HMDesign-OrgChart`.
|
||||
- 기존 브랜치: `feature/orgchart-package`; 새 브랜치를 생성하지 않았다.
|
||||
- 작업 전/후 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c`. Local `main` 및 기존 `origin/main` ref도 동일 SHA다. Git 쓰기 작업 없이 확인했다. 원격 HEAD는 이번 P1에서 새로 조회하지 않았다.
|
||||
- 공통 UI/Theme/Pattern, 기존 Wrapper 구현, 원본 JSON v1/v2, 기존 v0.1 구현·React 검증 보고서를 보존했다. 시작 시 기존 package.json/lock 수정 및 untracked 패키지 작업을 유지했다.
|
||||
- 시작 snapshot 671개 파일과 비교한 요청 범위 밖 변경은 0건이다(`test-results/org-chart-p1/preservation.json`). 기존 main checkout은 접근 제한으로 직접 Working Tree 검증하지 못했다. 그 경로에 수정 명령을 실행하지 않았다.
|
||||
- 별도 브랜치·Stage·Commit·Push·Merge를 수행하지 않았다. 작업은 미커밋 상태다. `git diff --check` 통과.
|
||||
|
||||
## 병렬 작업과 통합
|
||||
|
||||
사용자의 최초 P1 병렬 구현 요청에 따라 실제 두 Track을 병렬 실행했다. Track A는 공통 EmployeeCard, 조직별 presentation preset, 정식 로고 mapping, 독립 디자인 변수에 집중했다. Track B는 순수 summary selector, Position/Affiliation/Status DOM 모듈, 독립 Story 및 변경 범위 browser verifier를 담당했다. Main은 공통 controller/Tree/Content 통합, 공개 factory, 실제 Workdesk/Figma 근거 확인, 배포 예제와 canonical Story, 빌드·최종 증거 연결을 담당했다. 책임 파일을 분리했고 공유 파일 충돌은 없었다. 정상 테스트를 두 Track에서 반복하지 않았다.
|
||||
|
||||
## 디자인·콘텐츠 구현
|
||||
|
||||
1. `OrgChartContent`가 독립 Tree와 Summary를 조합한다. 리더·허브는 조직 트리의 가짜 parent를 만들지 않는다. 기존 organization ID/parentId를 유지한다.
|
||||
2. 최상위 리더, 조직장, 일반 직원은 같은 Core `createEmployeeCard`를 사용한다. 직위·직책·소속 로고·근무 상태를 표시하며 null/실패 프로필 이미지는 이니셜로 대체한다. React 카드 UI는 추가하지 않았다.
|
||||
3. 중앙 협업·소통 허브는 `presentation.hubText`로 표시한다. 추가 관계선은 구현하지 않았다.
|
||||
4. 사업관리·경영기획·인재성장·통합시스템 Div·디자인기획·솔루션영업과 확인된 셀/팀 명칭을 fixture에 사용한다. 6개 branch는 organization ID preset에 연결하고 하위 셀/팀이 variant를 상속한다. fixture membership/leader/직위는 가상 설정이다.
|
||||
5. 우측 PositionSummary는 organization × position 교차표, 직접/하위 포함 인원, 상태별 Raw Count를 같은 summary/selector에서 계산한다. 좁은 container에서는 표와 트리 각각 내부 스크롤을 제공한다.
|
||||
6. 하단 AffiliationSummary는 7개 관계사 명칭과 실제 로고, 가상 Raw Count를 표시한다. 0명 관계사 3곳도 표시한다.
|
||||
7. 파견·휴직은 독립 StatusSummary 안에서 원본 직원 참조로 표시한다. 전체 인원에 다시 더하지 않는다. 각 그룹 flag를 독립 적용한다.
|
||||
8. 카드·Typography·Spacing·Border·Radius·Gradient는 OrgChart 내부 CSS와 presentation preset으로 관리한다. 공통 HM Token을 필수로 사용하지 않고 Theme가 없는 호스트에서도 표시된다. 전용 public 변수만 외부 override에 사용한다. CSS는 Shadow DOM/내부 shell에 격리된다.
|
||||
9. Full Composition에 Header·GNB·LNB·페이지 Routing을 추가하지 않았다. Storybook 외부 검증 controls만 기존 공개 HM Button을 사용하며 Core 자체의 Theme 의존성과 구분한다.
|
||||
10. 같은 fake JSON, validation/normalize/summary/lifecycle, Core DOM/CSS를 HTML과 React가 공유한다. 기존 React Wrapper 구현과 Props API는 변경하지 않았다.
|
||||
|
||||
## 컬러와 로고 근거
|
||||
|
||||
Workdesk `https://workdesk.hmac.kr/`, 표시 버전 `v1.2610.9c5e`, 2026-10-12 관찰. 6개 visible 조직 heading 부모의 computed background-image와 heading white text를 읽었다.
|
||||
|
||||
- 사업관리 `.h33`: `#194d82 → #1d395c`.
|
||||
- 경영기획 `.h33.org-team-link`: `#2164a8 → #254975`.
|
||||
- 인재성장 `.h38.org-team-link`: `#0f6ace → #044a94`.
|
||||
- 통합시스템 Div `.h310.org-team-link`: `#8c67b6 → #5d3494`.
|
||||
- 디자인기획 `.h312.org-team-link`: `#09a7a2 → #00847f`.
|
||||
- 솔루션영업 `.h36.org-team-link`: `#5a963c → #3f7629`.
|
||||
|
||||
부서명 텍스트 실측값은 모두 `#ffffff`. 이는 헤더 측정이며 직원 카드 전체 원본 스타일을 검증한 값이 아니다. 카드 배경, 기본 Typography/Spacing/Radius 및 헤더 시작색을 재사용한 카드 테두리는 DESIGN_ADAPTATION으로 명시했다. 임의 기본값을 공식 Workdesk 값으로 확정하지 않았다. 기존 공통 Theme 값에 억지로 맞추지 않았다.
|
||||
|
||||
정식 로고 7종은 접근 가능한 Figma 파일 `Plr6RRLi5XnLIn60x9aG1s`의 footer `22:179`, logo nodes `22:181/187/193/199/205/211/217`에서 받은 PNG다. `(주)한맥기술`, `(주)삼안`, `(주)장헌`, `장헌산업`, `PTC`, `한라산업개발`, `바론컨설턴트`. affiliation ID로 Core map에 연결하며 같은 자산을 카드·하단 summary·Logo에서 사용한다. 원본 PNG/크기/SHA256/출처는 `packages/org-chart/src/assets/affiliations/README.md`에 기록한다. 임시 Figma URL은 배포에 필요 없고 bundle에 inline된다. 실제 프로필 사진·개인정보는 가져오지 않았다. 로고 미확보 항목은 이 7종 범위에서 없다. 미매핑 ID/파일 오류는 명칭 텍스트 fallback으로 처리한다.
|
||||
|
||||
## 공개 API·파일 변경
|
||||
|
||||
패키지 root에 `createOrgChartTree`, `createPositionSummary`, `createAffiliationSummary`, `createStatusSummary`, 타입 `OrgChartSummaryComponentOptions`를 additive export했다. 기존 API와 `0.1-draft` schema는 유지한다. Summary는 `stylesheetUrl` 하나만 필요하며 Tree는 기존 engine/core URL pair를 사용한다. 모든 factory는 기존 OrgChartHandle을 반환한다. 다른 업무 페이지의 container에 각각 mount/update/reload/destroy할 수 있으며 전체 OrgChart나 Header를 요구하지 않는다. [사용법과 override 예시](standalone-components-p1.md).
|
||||
|
||||
- 주요 Core 변경: `core/index.ts`, `render.ts`, `employee-card.ts`, `affiliation-mark.ts`, `style-presets.ts`, `src/index.ts`, `build.mjs`, `vendor.d.ts`.
|
||||
- 독립 모듈: `org-chart-content.ts`, `org-chart-tree.ts`, `position-summary.ts`, `affiliation-summary.ts`, `status-summary.ts`, `logo-legend.ts`, `department-colors.ts`, `components.ts`.
|
||||
- 데이터: `content-selectors.ts`와 신규 7개 selector 테스트. 기존 validator/schema/source/aggregation 계약을 바꾸지 않았다.
|
||||
- 스타일: `design-tokens.css`, `employee-card.css`, `summary-sections.css`, `org-chart-content.css`, `org-chart.css`. 빌드가 모든 내부 CSS를 `dist/style.css` 하나로 inline한다.
|
||||
- 예제: 가상 `data-p1.json`과 생성기, Theme 없는 `p1.html/p1-classic.html/p1-app.js/p1.css`, React `p1.html/p1.tsx/p1.css`; 기존 examples entry 유지. React Vite input에 P1 entry만 추가했다.
|
||||
- Storybook: Core component preview, card/internal summary Story, canonical P1 Story. 기존 8개 Story에는 `internal` tag만 추가하여 메뉴에서 숨기고 테스트/내용은 보존했다. 신규 card/status verification Story도 숨겼다.
|
||||
- 사용자용 OrgChart 메뉴는 **Logo / Department Colors / Position Summary / Affiliation Summary / Full Composition** 5개뿐이다. Department Colors는 실제 swatch/HEX/CSS variable, 카드 배경·직원 텍스트·부서명 텍스트·border/Gradient, OrgChart 전용 여부와 실측/기본값 근거를 표시한다.
|
||||
- 추가 npm dependency: 없음. 현재 React 19/Storybook 10.6.1/engine 6.0.0을 유지한다. 별도 Summary npm 패키지를 만들지 않았다.
|
||||
|
||||
## 검증 결과와 범위
|
||||
|
||||
- Core typecheck: 초기 점검 후 Department Colors의 literal union Set 타입 오류를 수정했다. 최종 Core 빌드에 포함된 TS declaration compiler 통과.
|
||||
- Story/source graph typecheck: `npm.cmd exec -- tsc -p tsconfig.app.json --noEmit` 최종 PASS.
|
||||
- Core ESM/IIFE/declaration/CSS build PASS. 공개 root import는 DOM 없는 Node에서 정상이고 기존 createOrgChart와 4개 신규 factory 모두 function으로 확인했다.
|
||||
- 기존 Wrapper typecheck/build PASS. 그 이후 Wrapper 구현은 변경하지 않았다.
|
||||
- HTML prepare, 별도 React consumer production build, Storybook production build PASS. 500kB chunk와 Storybook plugin timing 경고는 남았으며 오류는 아니다.
|
||||
- 새 selector 7개 테스트 **7/7 PASS**. 원본 data 테스트/기존 69·79 browser suite를 재실행하지 않았다.
|
||||
- 최초 신규 browser 검증 **197 checks: 196 PASS / 1 FAIL**. 유일 실패는 개별 meta의 storySort global-only 콘솔 오류였다. 해당 옵션을 제거하고 Storybook만 재빌드했다.
|
||||
- 오류 및 실제 메뉴 선택 재검증 **4/4 PASS**: 수정된 canonical 5개 iframe 콘솔 오류 0, 실제 manager/sidebar 5개 항목 정확 노출, 내부 검증 그룹 기본 숨김, manager 오류 0. 정상 196개 검사는 반복하지 않았다. 최초 report의 FAIL은 삭제하지 않고 후속 해소 증거로 연결한다.
|
||||
- Browser Chrome `155.0.8059.39`. HTML/React 가상41명, 18조직, 상태·소속·이동 JSON, 원본 집계/정책 미확정 메시지, group별 표시, keyboard collapse/expand/focus, 긴 이름/직위/직책, null/broken profile, 실로고 로딩·텍스트 fallback, destroy 검증 PASS.
|
||||
- `390/768/1280/1440px`: HTML/React/Full Composition 문서 overflow·글자 bounds·Navigation 없음 PASS. 트리/교차표 내부 overflow는 의도한 스크롤이다. 전체 6개 branch가 작은 폭에서 한 화면에 모두 보인다는 의미는 아니다.
|
||||
- Theme 미연결 HTML/React 실제 CSS PASS; host 공통 HM 변수 override에 영향 없음, public OrgChart override 적용 및 다른 instance/page에 영향 없음 PASS. 6개 조직별 Shadow DOM Gradient와 Department Colors legend/실제 카드색 일치 PASS.
|
||||
- 독립 Summary 3종 및 Tree의 공개 dist API mount/update/destroy PASS. Summary에 engine stylesheet 없고 Tree root leader가 누락되지 않음 확인.
|
||||
- 로컬 첫 브라우저 접근은 sandbox `ERR_NETWORK_ACCESS_DENIED`로 검사 전 중단됐다. 승인된 로컬 서버 환경에서 신규 검증을 실행했으며 운영 사이트를 수정하지 않았다.
|
||||
|
||||
증거 파일: `test-results/org-chart-p1/browser.json`, `storybook-console-recheck.json`, `preservation.json`, `contrast.json`, `storybook-build.log`; HTML/React/Full Composition 각 4개 폭 PNG와 `storybook-sidebar.png`. 원본 v0.1/React 보고서·검증 artifacts는 유지한다.
|
||||
|
||||
## Guard·명도 대비·남은 한계
|
||||
|
||||
Storybook PARTIAL Guard 원본: **status/staticStatus FAIL**, TOKEN-001 3개(`resolved-bg-surface`, `current-border`, `current-gradient`) 및 TOKEN-002 동적 변수 미검증. 최신 menu 파일 재검사도 같은 static FAIL을 보존한다. Guard는 `?url`로 가져온 private CSS를 일반 consumer CSS 적용으로 간주하지 않아 Shadow DOM 주입과 `style.setProperty` 선언을 static graph에서 연결하지 못한다. `resolved-bg-surface`는 `design-tokens.css`, current 변수는 `applyOrganizationStyle`에서 선언하며 실제 렌더의 6개 swatch/card/Gradient와 override 검증은 PASS다. 공통 Theme/Guard를 변경해 이 결과를 숨기지 않았다. 이 정적 FAIL을 Runtime PASS로 덮거나 전체 Guard PASS로 보고하지 않는다.
|
||||
|
||||
Storybook Style: **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED**(Guard 자체 기준). React consumer Guard: **status NOT VERIFIED / static NOT APPLICABLE**, RENDER-002 실제 bootstrap 브라우저 확인 요구; Style **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED**. 관련 브라우저 증거는 위의 별도 결과다. 원본 Guard JSON을 유지한다. 일반 HM React Component style override를 추가하지 않았다. OrgChart 독립 디자인 의도는 사용자의 2026-10-12 최종 지시를 근거로 적용했다.
|
||||
|
||||
흰색 텍스트와 측정 Gradient stop의 WCAG 대비를 계산했다. 일반 텍스트 4.5:1 기준: 통합시스템 시작색 4.44:1, 디자인기획 시작색 2.97:1, 솔루션영업 시작색 3.58:1은 미달한다. 다른 stop은 4.5:1 이상이다. 디자인기획 시작색은 큰 텍스트 3:1 기준도 약간 미달한다. 실측 디자인을 임의로 공식 보정하지 않고 원래 값을 유지했으며 접근성 개선 시 OrgChart 전용 department-color/gradient를 교체할 수 있다. 대비 검증을 전체 AA PASS로 표시하지 않는다. 기본 직원 foreground/흰 배경은 충분한 대비를 가진다.
|
||||
|
||||
Workdesk 최신 화면의 큰 배치와 실제 heading 컬러를 반영했으나 픽셀 일치, 카드 모든 spacing/radius/typography의 Figma 상세 정밀 측정은 NOT VERIFIED다. 실제 조직 parent/leader/membership 및 운영 인원은 Mock의 검증 대상이 아니다. Screen reader speech, 물리 touch, SSR/hydration, heap/GC 완전 회복은 NOT VERIFIED다. 원본이 비활성화한 pan/zoom/drag/export는 추가하지 않았다. 복잡한 협업 관계선·직원 상세·정책 승인 및 실제 HR/API 전환은 P2 범위다.
|
||||
|
||||
## 시간 기록 (KST)
|
||||
|
||||
- Started At: 2026-10-12 10:49:28 KST
|
||||
- Completed At: 2026-10-12 11:32:48 KST
|
||||
- Actual Duration: 0시간 43분 20초
|
||||
- Estimated Duration: 최초 2–3시간
|
||||
- Variance: 예상보다 76분 40초–136분 40초 단축
|
||||
|
||||
Commit·Push·Merge: **진행하지 않음**.
|
||||
@@ -0,0 +1,109 @@
|
||||
# OrgChart P2 — Depth별 카드와 Workdesk Full Composition
|
||||
|
||||
RESULT: **PASS — 현재 P1 fixture의 조회 전용 Full Composition 수정 범위**.
|
||||
|
||||
실제 조직 계층에 따라 1Depth 조직장만 대형 카드를 사용하고, 2Depth 조직장은 소형 카드로 표시한다. Workdesk 원본 연결선·협업 화살표·장식을 공통 Core에 적용했다. 기존 Guard 미검증과 이전 검사의 FAIL은 이 결과와 별도로 보존한다. Workdesk 전체 픽셀 일치나 모든 데이터 조합의 지원을 뜻하지 않는다.
|
||||
|
||||
## 작업 범위와 보존
|
||||
|
||||
- 수정 경로: `D:\디자인시스템\HMDesign-OrgChart`, 브랜치 `feature/orgchart-package`.
|
||||
- Workdesk 원본 `D:\디자인시스템\gpd-workdesk\sample\조직도_v1.3`는 읽기 전용으로 참조했다. 운영 main에는 수정하지 않았다.
|
||||
- 기존 미커밋 변경은 시작 파일 해시와 비교해 보존했다. 원본 카드 SVG·회사 로고·공통 Theme·Component·Pattern·Storybook 메뉴·독립 컴포넌트 API를 유지했다.
|
||||
- 직원 41명의 원본 fixture는 변경하지 않았다. 직원 ID·이름·리더 정보·직위·소속·상태·조직 관계·집계 계약도 그대로다.
|
||||
- Stage·Commit·Push·Merge·Reset·Rebase를 수행하지 않았다.
|
||||
|
||||
## 카드·이름·간격
|
||||
|
||||
- 변경 전: 하위 조직장도 모두 대형 `leader` 자산을 사용했다. 조회 전용 외부 margin이 후속 직원 margin 규칙에 덮여 실제 헤더↔카드 간격은 0px이었다.
|
||||
- 변경 후 대표자: 전용 SVG **146×74px** 유지.
|
||||
- 변경 후 1Depth: 주요 조직장 6명 모두 기존 대형 SVG **146×68px** 사용.
|
||||
- 변경 후 2Depth: fixture의 기존 조직장 3명 모두 소형 SVG **108×52px** 사용. 다른 일반 직원도 소형 카드를 유지한다.
|
||||
- 대형 여부는 직책 문자열이나 리더 플래그 단독이 아니라 원본 `parentId` 조상 관계로 계산한다. semantic leader 구분과 직책 뱃지는 보존하며 독립·interactive 화면의 카드 정책은 유지한다.
|
||||
- 최신 사용자 수정 기준으로 1Depth 외부 간격은 **8px**, 2Depth 이하 헤더↔첫 카드 간격은 **10px**다. 화면의 17개 조직 노드에서 Depth별 간격을 확인했다.
|
||||
- Storybook 1920px 측정값을 Stage 좌표로 환산하면 1Depth 헤더 Y **254.1875px**, 높이 **36px**, 대형 카드 상단 **298.1875px**다. 6개 외부 간격은 약 **8.000002px**, 하위 조직 간격은 **9.99998–10.00001px**였다.
|
||||
- Full Composition의 대표자·조직장·직원·파견·휴직을 포함한 렌더 카드 43개의 이름은 모두 **홍 길 동**으로 표시한다. 접근성 이름은 **홍길동**이다. 직위·직책도 요청한 한글 자간 표시를 적용하며 원본 데이터는 변경하지 않는다.
|
||||
- 확정 Typography·카드 크기·프로필 크기와 직위↔이름 내부 gap **0px**를 유지했다. 외부 헤더 간격과 내부 행 간격을 분리했다.
|
||||
|
||||
## Workdesk 원본 디자인 적용
|
||||
|
||||
- 활성 compact `screenD` 곡선 3개, 대표자↔허브 중앙선, 실제 헤더 상단 중앙을 따라가는 사업관리 곡선의 **SVG Path 5개**를 적용했다. 사업관리 곡선은 원본 renderer의 계산식을 사용한다.
|
||||
- Stroke `#45639F`, width `0.893023`, dasharray `1.79 1.79`, fill `none`을 사용한다. 기존 상단 pseudo 연결선은 해당 원본 artwork preset에서 숨겨 중복을 제거했다.
|
||||
- 원본에서 하위 조직 SVG 블록은 비활성 상태이며 현재 사용 가능한 별도 원본 Path가 없다. 하위 셀·팀은 기존 엔진의 실제 계층 경로를 보존하고 원본 점선 스타일을 적용했다. 원본에 없는 관계를 추가하지 않았다.
|
||||
- 협업 화살표는 원본 PNG 4종으로 **5개**를 배치했다. 원본 SVG image 방식과 `preserveAspectRatio="none"`을 유지한다.
|
||||
- `Group.svg`, `bg_sky.png`, `bg_main.png`, `Frame2@3x.png`, `1-8-4x-1@2x.png`와 화살표 4종, 총 **9개 자산**을 복사했다. SHA-256이 Workdesk 원본과 모두 일치한다.
|
||||
- `?inline` import로 Core ESM/IIFE에 자산을 포함해 외부 Workdesk 서버 의존성을 제거했다. HTML·React·직접 열기 classic에서 동일한 자산을 사용한다.
|
||||
- 장식 0, SVG 선·화살표 4, 조직 7, 허브 12, 통계 18, 파견·휴직 32, 대표자 35의 레이어를 구성했다. 전체 artwork는 카드와 같은 Stage 안에서 함께 축소된다.
|
||||
- 우측 조직별 총인원, 좌하단 파견·휴직의 가로 2개 그룹, 하단 관계사 한 줄 현황을 유지한다. 독립 PositionSummary의 상세 교차표 기능은 보존했다.
|
||||
|
||||
## HTML·React·Storybook과 반응형
|
||||
|
||||
- HTML `p1.html?mode=readOnly`, 직접 열기 `p1-classic.html?mode=readOnly`, React `p1.html?mode=readOnly`, 기존 Full Composition Story가 같은 Core DOM/CSS를 사용한다.
|
||||
- React 예제에 HTML과 같은 `mode` query 처리를 연결했다. 별도 React 노드 UI를 만들지 않았다.
|
||||
- 기준 Stage **1920×914.5px**와 기존 폭·높이 기반 단일 비례축소를 유지한다. 작은 화면에서 카드 종류를 바꾸거나 조직을 재배열하지 않는다.
|
||||
- 1920×1080: scale **0.983333**.
|
||||
- 1440×900: scale **0.733333**.
|
||||
- 1280×800: scale **0.650000**.
|
||||
- 768×1024: scale **0.383333**.
|
||||
- 390×844: scale **0.186458**.
|
||||
- 네 진입 경로의 조직·카드·간격·통계·자산·축소 측정값이 일치했다. Storybook의 베이지 호스트 배경과 HTML의 흰 배경 때문에 바깥 배경 톤은 다르다.
|
||||
- 390px에서도 구조 전체가 함께 축소되지만 글자는 매우 작다. 모바일에서 원본 크기 가독성을 제공한다는 의미는 아니다.
|
||||
|
||||
## 검증과 근거
|
||||
|
||||
- OrgChart Core·React Wrapper Typecheck: **PASS**.
|
||||
- Core ESM/IIFE·타입·CSS 및 Wrapper Build: **PASS**.
|
||||
- React 소비 예제 production Build: **PASS**. 약 2.53MB chunk 경고는 원본 이미지 inline 포함에 따른 것으로 남겨 두었다.
|
||||
- 현재 Storybook dev **6041**의 Full Composition: **PASS**. 전체 Storybook production Build는 이번에 재실행하지 않았다.
|
||||
- Chrome `155.0.8059.39`, 네 진입 경로·다섯 뷰포트의 P2 브라우저 검사: **484/484 PASS**, 새 console/runtime 오류 **0**.
|
||||
- 대표자·Depth별 카드 크기, 실제 조상 Depth, 17개 조직의 외부 간격, 이름·직책 뱃지·원본 fixture 보존, Stage geometry·overflow, 카드·헤더·화살표·통계의 사각형 겹침을 확인했다.
|
||||
- Tooltip 검증은 실제 표시된 홍 길 동 이름 1개의 폭을 일시적으로 **8px**로 제한해 overflow를 유도했다. 실제 hover·tooltip hover·키보드 Tab·Escape·focus 유지·숨김 scroll extent·복구를 확인하고 폭을 원복했다. fixture나 표시 이름을 검증용 긴 이름으로 바꾸지 않았다.
|
||||
- 1920/1440/390px HTML·Storybook PNG를 육안 확인했다. 실제 텍스트 glyph 충돌과 화살표의 카드 침범은 발견하지 않았다. 확정 내부 gap 0에서 Noto 글꼴의 DOM Range ascent/descent envelope가 교차할 수 있으므로 해당 원시 측정값은 실제 glyph 충돌과 구분해 보존했다.
|
||||
- 곡선 Path의 bounding box만으로 실제 곡선 겹침을 판정하지 않았다. 라우팅·레이어는 저장된 전체 화면 캡처로 확인했고 자동 곡선 충돌 검증은 **NOT VERIFIED**다.
|
||||
|
||||
증거 파일:
|
||||
|
||||
- [P2 browser 결과·측정값](../../test-results/org-chart-p2/browser.json).
|
||||
- [기존 소스 변경 범위](../../test-results/org-chart-p2/source-changes.json), [시작 해시](../../test-results/org-chart-p2/source-baseline.json), [원본 자산 SHA 비교](../../test-results/org-chart-p2/asset-preservation.json).
|
||||
- [Storybook 1920](../../test-results/org-chart-p2/storybook-1920x1080.png), [1440](../../test-results/org-chart-p2/storybook-1440x900.png), [390](../../test-results/org-chart-p2/storybook-390x844.png).
|
||||
- [HTML 1920](../../test-results/org-chart-p2/html-1920x1080.png), [1440](../../test-results/org-chart-p2/html-1440x900.png), [390](../../test-results/org-chart-p2/html-390x844.png).
|
||||
- [Storybook Full Composition](http://127.0.0.1:6041/?path=/story/orgchart--full-composition), [독립 iframe](http://127.0.0.1:6041/iframe.html?id=orgchart--full-composition&viewMode=story).
|
||||
|
||||
## Guard·기존 FAIL·미검증
|
||||
|
||||
- React 소비 예제에 PARTIAL Guard를 실행했다. 원본 `staticStatus=NOT APPLICABLE`, `status=NOT VERIFIED`, `RENDER-002`를 보존한다.
|
||||
- `styleEvidence`: STATIC **PASS**, RUNTIME **NOT VERIFIED**, DESIGN_INTENT **PASS**, FINAL **NOT VERIFIED**. Guard CSS checks는 `[]`이며 Core Shadow CSS를 검사 범위에 포함하지 못했다. 이를 Core 스타일 전체 정적 PASS로 확대하지 않는다.
|
||||
- [Guard 원본](../../test-results/org-chart-p2/guard-react.json)과 실제 P2 브라우저 증거는 별도 파일로 보존하며 Runtime PASS로 원본 status를 덮어쓰지 않았다.
|
||||
- 기존 `verify-p1.mjs` [baseline](../../test-results/org-chart-full-composition/verify-p1-baseline.json)·[after](../../test-results/org-chart-full-composition/verify-p1-after.json)의 **197건 중 145 PASS / 동일 52 FAIL**을 보존하고 재실행하지 않았다. 이전 fixture·root·교차표·내부 스크롤·Typography 기대값 관련 실패와 P2 검사를 구분한다.
|
||||
- 사용자가 언급한 v0 `verify.mjs` FAIL **9건**은 이번에 독립 확인하지 못했다. 현재 저장된 [v0 report](../../test-results/org-chart-browser/report.json)는 **6건 중 2 FAIL**로, 이 기록만으로 9건을 확정하지 않는다. 검사 삭제나 무조건적인 기대값 PASS 변경은 없다.
|
||||
- **NOT VERIFIED**: 실제 운영 Workdesk 최신 화면과의 완전한 픽셀 일치, 전체 스크린리더, 자동 곡선/글꼴 ink 충돌 판정, 모든 미매핑 조직·혼합 root 데이터 조합, 전체 Storybook production Build.
|
||||
|
||||
## 변경 파일
|
||||
|
||||
기존 소스 6개:
|
||||
|
||||
- [render.ts](../../packages/org-chart/src/core/render.ts): 실제 조상 Depth에 따른 readOnly 카드 자산·헤더 역할.
|
||||
- [employee-card.ts](../../packages/org-chart/src/core/employee-card.ts): readOnly 표시 이름·한글 간격·접근성 이름.
|
||||
- [status-summary.ts](../../packages/org-chart/src/core/status-summary.ts): 상태 카드에도 동일 표시 옵션 전달.
|
||||
- [org-chart-content.ts](../../packages/org-chart/src/core/org-chart-content.ts): 대표자 표시와 공통 artwork 조합.
|
||||
- [org-chart-readonly.css](../../packages/org-chart/src/styles/org-chart-readonly.css): 헤더·카드 간격, 원본 장식·레이어·계층선, 파견/휴직 가로 그룹.
|
||||
- [React p1.tsx](../../examples/org-chart-react/p1.tsx): HTML과 같은 readOnly query 진입.
|
||||
|
||||
추가 소스·자산·문서:
|
||||
|
||||
- [workdesk-artwork.ts](../../packages/org-chart/src/core/workdesk-artwork.ts): 원본 Path·5개 image 화살표·장식·동적 사업관리 연결선.
|
||||
- [Workdesk 자산 README](../../packages/org-chart/src/assets/workdesk/README.md) 및 해당 디렉터리의 원본 자산 9개.
|
||||
- [verify-p2.mjs](../../examples/org-chart/verify-p2.mjs): 기존 검사와 분리된 P2 브라우저 검증.
|
||||
- 이 보고서와 현재 계약을 반영한 [UI Composition](ui-composition.md), [카드 디자인 2단계](card-design-stage2.md).
|
||||
- [React 예제 README](../../examples/org-chart-react/README.md): 조회 전용 query와 P2 검증 실행 안내.
|
||||
|
||||
패키지·HTML/React build 산출물과 `test-results/org-chart-p2/` 증거를 생성했다. 이전 보고서·검증 기록을 보존했으며 Commit·Push·Merge는 미진행이다.
|
||||
|
||||
별도 [런타임 증거](../../test-results/org-chart-p2/runtime-evidence.json)에 원본 Guard 해시와 브라우저·시각 검토 상태를 연결했다. 현재 source/contract fingerprint가 원본 Guard와 일치함을 확인했다. 원본 status는 변경하지 않았다. 카드 41개의 직위/뱃지 행 하단과 이름 행 상단 간격도 실측 0px(부동소수점 오차 내)이다.
|
||||
|
||||
## 시간 기록
|
||||
|
||||
- Started At: 2026-10-12 14:52:51 KST
|
||||
- Completed At: 2026-10-12 15:15:24 KST
|
||||
- Actual Duration: 22분 33초
|
||||
- Estimated Duration: 약 45~60분
|
||||
- Variance: 예상보다 22분 27초~37분 27초 단축
|
||||
@@ -0,0 +1,43 @@
|
||||
# HMDesign OrgChart P3.1 — 잔여 디자인 정밀 보완
|
||||
|
||||
RESULT: **PASS — 요청한 P3.1 변경 범위**.
|
||||
|
||||
집중 브라우저 검사 **486/486 PASS**, 신규 콘솔·런타임 오류 **0건**. 기존 [P3의 732/732 기록](implementation-report-p3.md)은 역사 근거로 보존했고 전체 suite를 재실행하지 않았다. Guard 원본 및 결합 전체 상태는 **NOT VERIFIED**로 유지한다.
|
||||
|
||||
1. **Noto Sans KR**: OrgChart 전용 기본 token에 적용했다. 기존 `public/fonts/NotoSansKR-variable.ttf`를 HTML의 기존 복사 경로와 React bundler 경로에서 재사용하고 Storybook의 기존 HM font-face를 사용한다. HM Theme는 변경하지 않았다. 카드·직위·타이틀·허브·표·관계사·파견/휴직·팝업의 computed family를 확인했으며 네 실행 경로의 대표 9개 텍스트에서 Chrome CDP로 실제 custom-font 글리프 사용을 확인했다. 파일 내부 family 이름은 `Noto Sans KR Thin`, PostScript 이름은 `NotoSansKR`와 가중치별 suffix다. 이는 fallback 글꼴이 아니다. 한국어 Core shell의 `lang="ko"`로 `en` Storybook host와 `ko` HTML의 가운데점 글리프 폭 차이도 제거했다.
|
||||
2. **1Depth·2Depth**: 1Depth를 20px/700/36px·실제 헤더 36px로 맞췄다. 경영기획 2Depth는 원본 `.hw`의 18px/600/16.9px를 적용했다. 인재성장은 같은 기존 값, 나머지는 20px/700/19.7px를 유지한다. 2Depth 114×30.4px·기존 Gradient·Radius 7.1px·Shadow·정렬을 유지했다. 모든 타이틀↔첫 카드 Gap 8px를 실제 측정했다.
|
||||
3. **경영진 뱃지**: Full Composition의 `workdeskPresentation`에서 `jobTitle.trim() === '경영진'`인 뱃지 DOM만 생성하지 않는다. 빈 attribute container도 생성하지 않는다. 실제 직위·직책 데이터·팝업·통계는 그대로다. 현재 fixture의 대표자 `position="경영진"`, `jobTitle="실장"`은 유지한다. 따라서 대표자 직위 글자와 표의 `경영진` 열은 정상적으로 남는다.
|
||||
4. **기타 뱃지·이름**: 실장·팀장·디비전장 등의 원본 뱃지는 유지한다. 이름은 기존 `홍 길 동`, 직위는 표시 계층에서만 공백을 제거한다. JSON을 수정하지 않는다. 기존 fixture에 경영진 직책 뱃지가 없어 대표자/1Depth/2Depth/일반/파견/휴직의 메모리 전용 변형을 별도로 검사했다. 여섯 역할에서 뱃지 미표시·직위 유지, 팝업의 원본 직책 보존, interactive 공통 렌더러의 별도 표시 정책을 확인했다.
|
||||
5. **허브**: 원본 21px/750/24px·아이콘 20px·Gap 8px·좌우 Padding 22/24px·높이 42px를 유지했다. 기존 상속 `max-width:100%`가 198px 축 폭으로 텍스트를 제한하던 부분을 해제했다. 중심을 X 935에 명시하여 실제 폭 약 219.016px에서도 연결선 축을 유지한다. 기존 말풍선 SVG·배경·곡선은 변경하지 않았다.
|
||||
6. **사진 없음**: 신규 공통 `person-placeholder.svg` 한 종을 모든 카드에서 같은 inline 자산으로 사용한다. 연한 사각 배경·중립 사람 실루엣이며 실사/원형 아바타/장식 없음. 실제 이미지 우선, 이미지 decode 실패 시 공통 SVG fallback을 확인했다. 승인 카드의 예약 슬롯 36×62/32×56/24×44 및 P3 파견/휴직 28×28을 유지한다. 원본 슬롯 55×76/54×70/38×52는 승인 카드보다 커서 복사하지 않았다.
|
||||
7. **인원 현황 표**: 기존 표 폭 330px·위치·14px/400/1.15·좌우 조직명 padding 7px·행 19px/합계 20px·정렬·Border를 유지했다. 조직명 열 112px, 경영진 열 46px, 나머지는 잔여 폭을 배분한다. `nowrap`을 적용했으며 모든 셀의 실제 텍스트 Range가 셀 내부 한 줄로 들어가는지 확인했다. 숨김·말줄임·글자 축소 없음. 표 높이는 약 200.375px에서 172px로 줄었다. 가상 41명·조직별/직위별 계산·관계사 바는 보존했다.
|
||||
8. **변경 파일**: 구현·예제·문서 14개. 재생성된 dist와 전체 해시 차이는 [파일 기록](../../test-results/org-chart-p3.1/source-changes.json)에 있다.
|
||||
|
||||
- `packages/org-chart/src/core/employee-card.ts`, `src/core/index.ts`
|
||||
- `packages/org-chart/src/styles/design-tokens.css`, `employee-card.css`, `org-chart-readonly.css`, `org-chart-p3-summary.css`
|
||||
- 신규 `packages/org-chart/src/assets/person-placeholder.svg`
|
||||
- `examples/org-chart/p1.css`, `examples/org-chart-react/p1.css`, 신규 `examples/org-chart/verify-p3.1.mjs`
|
||||
- `packages/org-chart/README.md`, `docs/org-chart/ui-composition.md`, `card-design-stage2.md`, 이 보고서
|
||||
|
||||
9. **화면별 결과**: HTML HTTP·직접 `file://` classic·React production HTTP·실제 Storybook 6041 모두 1920×1080, 1440×900, 1280×800, 768×1024, 390×844에서 PASS. 문서 scroll/client 폭·높이가 같고 모든 카드가 Stage 내부에 들어간다. 모바일은 기존 전체 비례 축소라 글자가 작지만 팝업은 축소하지 않는다. [1920 HTML](../../test-results/org-chart-p3.1/html-1920x1080.png), [1920 Storybook](../../test-results/org-chart-p3.1/storybook-1920x1080.png), [390 Storybook](../../test-results/org-chart-p3.1/storybook-390x844.png)을 육안 검토했다.
|
||||
10. **HTML·React 동일성**: 네 경로의 카드 내용/좌표/크기·허브 위치/폭·표 내용/폭을 화면별로 비교하여 일치했다. 같은 Core와 SVG를 사용하며 HTML 모방 화면을 만들지 않았다. React Wrapper와 Storybook 소스·사용자 메뉴 세 개는 변경하지 않았다.
|
||||
11. **회귀·검증**: Core/Wrapper Typecheck, Core ESM/IIFE/타입/CSS build, Wrapper build, HTML prepare, React 소비 production build PASS. 여섯 카드 역할의 데스크톱/모바일 팝업·focus trap·ESC 복귀, 대표자 Enter/Space/닫기, HTML/React의 실제 키보드 그룹 링크 이동을 확인했다. 146×74/146×68/108×52 카드 및 P3 상태 카드 114×54 보존. 상태 카드 114×54와 슬롯 28×28은 이번 도입값이 아니라 기존 P3 측정값이다. 기존 큰 이미지 inline chunk 약 2.54MB 경고는 유지한다. 마지막 들여쓰기 정리 뒤 재빌드한 runtime 파일 73개는 검증 시점과 byte 동일하여 브라우저를 반복하지 않았다.
|
||||
12. **남은 Workdesk 차이**: 원본 Pretendard 대신 이번 확정 Noto Sans KR을 적용한다. 승인 카드 때문에 원본보다 작은 사진 슬롯을 유지한다. 표 열/행과 인원은 현재 가상 fixture를 따른다. 원본 운영 화면과 완전한 픽셀 일치 및 곡선/글리프 ink 자동 교차 검사는 NOT VERIFIED다. 확장형 Auto Layout/layoutConfig API 없음.
|
||||
13. **Guard·NOT VERIFIED**: 최종 유효 PARTIAL Guard는 React bootstrap `p1.html`에 실행했다. 원본 staticStatus `NOT APPLICABLE`, status `NOT VERIFIED`, `RENDER-002`를 보존한다. 원본 및 결합 CSS 상태는 STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED다. 별도 Runtime은 PASS이며 이름 있는 computed 측정은 PASS이나 Core Shadow CSS 전체의 정적/의도 인증으로 확대하지 않는다. 최초 Guard는 target 기준 result 경로를 중복 지정해 ENTRY/SCOPE 미검증이 발생했다. 해당 실행은 [잘못된 인자 진단](../../test-results/org-chart-p3.1/guard-initial-invalid-result-path.json)으로 보존하고 인자만 바로잡았다. 실제 운영 Workdesk 픽셀 일치, 모든 임의 데이터/테마/브라우저, 스크린리더 발화, 실물 touch, 전체 Storybook production build는 NOT VERIFIED다.
|
||||
14. **보존·Git**: 작업 경로 `D:\디자인시스템\HMDesign-OrgChart`, 브랜치 `feature/orgchart-package`, HEAD `d18e3c33801e165e17b6d971e2f6198eed483c3c` 유지. Workdesk 원본·운영 main 미수정. JSON·승인 SVG·로고·Workdesk 장식·HM Theme·Guard·과거 검사/보고서·package manifests 등 보존 대상 216개 해시 동일. 시작 시 기존 미커밋 package.json/package-lock.json 및 OrgChart 작업을 보존했다. Stage·Commit·Push·Merge·Reset·Rebase·Force Push 미진행.
|
||||
|
||||
초기 집중 검사 102 FAIL도 [초기 기록](../../test-results/org-chart-p3.1/browser-initial.json)에 남긴다. 다섯 화면의 실행 경로 동일성 실패는 host 언어에 따른 허브 폭 차이였고 `lang="ko"`로 수정했다. 나머지 97건은 기존 상태 카드 크기/slot, `.hw` ID, variable font 내부 이름, fixture에 없는 뱃지에 대한 검사 기준을 정확히 잡지 못한 진단 오류였다. 원본 P3의 기대값이나 FAIL 기록은 수정하지 않았다.
|
||||
|
||||
검증 근거: [최종 browser JSON](../../test-results/org-chart-p3.1/browser.json), [원본 Guard](../../test-results/org-chart-p3.1/guard-react.json), [Runtime](../../test-results/org-chart-p3.1/runtime-evidence.json), [결합 증거](../../test-results/org-chart-p3.1/linked-evidence.json), [runtime byte 동등성](../../test-results/org-chart-p3.1/artifact-format-equivalence.json).
|
||||
|
||||
Workdesk 읽기 전용 근거: `index.css`의 `.hw` 2858–2865, 그룹 헤더 3703–3751, 2Depth 표면 3776–3786, 폭 3884–3899, 허브 2472–2542, 표 1432–1673; `index.html` 255–282 및 `org-dashboard-renderer.js` 1638–1663. P3가 남긴 원본 수치 근거도 보존한다.
|
||||
|
||||
미리보기: [Storybook Full Composition](http://127.0.0.1:6041/?path=/story/orgchart--full-composition).
|
||||
|
||||
작업 시간:
|
||||
|
||||
- Started At: 2026-10-12 16:01:00 KST
|
||||
- Completed At: 2026-10-12 16:30:44 KST
|
||||
- Actual Duration: 29분 44초
|
||||
- Estimated Duration: 35–50분
|
||||
- Variance: 예상보다 5분 16초–20분 16초 단축
|
||||
@@ -0,0 +1,73 @@
|
||||
# HMDesign OrgChart P3.2 — 카드 Typography·파견/휴직·로고 크기 보정
|
||||
|
||||
RESULT: **PASS — 요청한 P3.2 변경 범위**. 집중 브라우저 **431/431 PASS**, 정책 측정 보완 **13/13 PASS**, 신규 콘솔·런타임 오류 **0건**. Typecheck·관련 Build PASS. P3.1의 486건 전체 suite를 반복하지 않았으며 기존 보고서·검증 증거 115개 파일을 보존했다. Guard 원본과 결합 전체 상태는 **NOT VERIFIED**로 유지한다.
|
||||
|
||||
## 변경 전후 px
|
||||
|
||||
아래는 실제 Chrome computed style과 Stage 축소 배율을 제거한 크기다. 측정 원본의 소수점 오차는 약 0.0001px 이내이며 아래 정수는 반올림한 값이다. [43개 카드 전체 측정](../../test-results/org-chart-p3.2/before-after-px.json)에 원시 값·컬러·로고 alpha 영역을 남겼다.
|
||||
|
||||
- **1Depth 대형 카드**: 이름 **24→24px**, weight 600·line-height 30px 유지. 직위 **20→18px**, weight 400·line-height 22px 유지. 기존 이름 #071D3A·직위 #797979·letter-spacing 유지. 카드 **146×68**, 사진 **32×56**, 사진↔텍스트 **10px** 유지.
|
||||
- **2Depth/일반 소형 카드**: 이름 **20→18px**, weight 600·line-height 24px 유지. 직위 **15→15px**, weight 400·line-height 18px 유지. 기존 이름 #323232·직위 #797979·letter-spacing 유지. 카드 **108×52**, 사진 **24×44**, 사진↔텍스트 **4px** 유지. 2Depth의 semantic leader도 동일한 소형 규격이다.
|
||||
- **대표자**: 이름 **24px/600/30px**, 직위 **20px/400/22px**, 기존 #071D3A/#224387·letter-spacing 유지. 카드 **146×74**, 사진 **36×62** 유지.
|
||||
- **파견·휴직 타이틀**: **14px/700/20px → 20px/700/19.7px**. 일반 2Depth의 기본 Typography와 같은 CSS 변수 세 개를 공유한다. 전용 회색 Gradient, Border·Shadow·108×24 타이틀 표면은 유지한다. 경영기획·인재성장의 기존 **18px/600/16.9px** 예외는 변경하지 않았다.
|
||||
- **파견·휴직 직원**: 이름 **13px/700/18px → 18px/600/24px**, 직위 **10px/400/13px → 15px/400/18px**. 일반 소형 카드와 같은 콘텐츠 DOM·Typography 변수·사진 영역·텍스트 좌표·간격을 재사용한다. 이름 #3E4650·직위 #555F6A, 기존 회색 Gradient·Border·Radius·로고 grayscale(1)/opacity 0.72 유지. 이름 아래 별도 상태 문구 없음.
|
||||
- **파견·휴직 크기 차이**: P3.1 실제값은 카드 **114×54**, 사진 **28×28**이었다. 이번 명시된 최신 요구값인 카드 **108×52**, 사진 **24×44**로 보정했다. 이를 기존 108×52를 그대로 보존한 것으로 표현하지 않는다. 1px Border를 유지하면서 padding을 3px로 조정하여 일반 소형의 4px 사진 원점·32px 텍스트 원점과 일치한다.
|
||||
- **타이틀↔첫 카드 Gap**: 일반 조직 및 파견·휴직 모두 **8→8px**. 직위↔이름 행 Gap **0px**.
|
||||
- **Font/name/badge**: Noto Sans KR·기존 호스트 font-face/폰트 파일 유지. 이름은 **홍 길 동**, 직위·뱃지의 글자 사이 공백은 표시에서만 제거한다. 직원 JSON·팝업의 원문 데이터는 변경하지 않는다.
|
||||
|
||||
## 카드 로고 적용 범위
|
||||
|
||||
렌더링된 43개 직원 카드(대표자 1, 대형 6, 소형 34, 파견·휴직 2) 모두 기존 회사/관계사 PNG를 실제로 표시한다. 원본 SVG·PNG를 수정하지 않고 직원 카드 selector에만 확대를 적용했다.
|
||||
|
||||
- **소형**: 기존 실제 contain 영역 **12×12→18×18px**, 정확히 **1.5배**. 컨테이너는 투명한 1px inset을 포함해 14×14→20×20이다. 카드 기준 image origin **(88, 3)px**.
|
||||
- **대형·대표자**: 실제 contain 영역 **14×14→21×21px**, 정확히 **1.5배**. 컨테이너 16×16→23×23, image origin **(124, 1)px**. top/right 0으로 옮겨 뱃지와의 여유를 확보했다.
|
||||
- **파견·휴직**: 실제 contain 영역 **14×14→21×21px**, 정확히 **1.5배**. image origin **(85, 1)px**. 파견의 이전 CSS image box는 14×24였지만 object-fit:contain으로 원본 정사각 로고가 실제 표시된 영역은 14×14였다. 원시 box 높이를 단순 1.5배하는 방식으로 확대하지 않았다.
|
||||
- 모든 원본은 transparent padding을 포함하므로 이미지 box와 실제 유색 로고 영역은 다르다. 예를 들어 한맥 21px box의 유색 범위는 약 **15.5835×9.5011px**다. **contain 영역과 PNG alpha 영역의 폭·높이 모두 1.5배**를 비교했다.
|
||||
- 원본 비율 유지, 추가 배경·테두리·그림자 없음. 승인 카드 전체 크기 변경 없음. 로고 alpha 영역과 이름·직위·뱃지·프로필 영역 충돌 **0건**, 카드 밖 잘림 **0건**. 이번 fixture에서 1.5배를 모두 수용하므로 별도 최대 크기 제약은 없다.
|
||||
- 하단 관계사별 인원 현황의 로고 box·바 전체 좌표·집계 문자열은 이전 측정과 동일하다. 확대 selector는 footer에 적용되지 않는다.
|
||||
|
||||
## 수정 파일
|
||||
|
||||
기존 파일 **6개**를 변경하고 **2개**를 추가했다. 기존 미커밋 package.json/package-lock.json 및 OrgChart 작업은 보존했다.
|
||||
|
||||
1. [employee-card.ts](../../packages/org-chart/src/core/employee-card.ts): readOnly 상태 카드의 공통 compact 콘텐츠 DOM, 표시 뱃지 공백 제거. 공개 API 변경 없음.
|
||||
2. [employee-card.css](../../packages/org-chart/src/styles/employee-card.css): 요청 Typography, 일반/상태 공통 스타일, 카드 한정 로고 확대·위치.
|
||||
3. [org-chart-readonly.css](../../packages/org-chart/src/styles/org-chart-readonly.css): 일반 2Depth/상태 타이틀 Typography 변수 공유, 대형 로고 top 조정.
|
||||
4. [ui-composition.md](ui-composition.md): P3.2 최신 계약 추가. 과거 절 보존.
|
||||
5. [card-design-stage2.md](card-design-stage2.md): P3.2 우선 안내 추가. 과거 절 보존.
|
||||
6. [Core README](../../packages/org-chart/README.md): P3.2 현재 규격·적용 범위 추가.
|
||||
7. 신규 [verify-p3.2.mjs](../../examples/org-chart/verify-p3.2.mjs): 변경 전 capture, 변경 항목 집중 검사, 정책 측정 보완 모드.
|
||||
8. 신규 이 보고서.
|
||||
|
||||
관련 Core ESM/IIFE/타입/CSS, Wrapper, HTML example dist, React production dist만 재생성했다. 기존 package manifests/lock·JSON·승인 카드 SVG·로고 PNG·공통 사람 아이콘·Storybook 소스·Theme는 보존한다. 기존 파일 해시와 6개 변경 파일 diff는 [source-changes](../../test-results/org-chart-p3.2/source-changes.json) 및 같은 디렉터리의 diff/에 기록했다.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
- **PASS**: [집중 브라우저 431/431](../../test-results/org-chart-p3.2/browser.json). HTML HTTP, 직접 file:// classic, React production HTTP, 실제 Storybook의 **1920×1080 / 1440×900 / 1280×800 / 768×1024 / 390×844**, 총 20 captures. 요청 Typography·상태 규격·로고 1.5·카드 크기·Gap·사진 아이콘·집계·하단 로고·경영진 뱃지 미표시를 확인했다.
|
||||
- **PASS**: 모든 화면의 document scrollWidth=clientWidth, scrollHeight=clientHeight. 모든 카드와 콘텐츠는 Stage 안에 들어간다. 기존 전체 자동 축소 정책을 유지한다. 390px는 기존처럼 전체 비례 축소되어 글자가 작다.
|
||||
- **PASS**: 대표자·1Depth·소형·파견·휴직 팝업을 네 실행 경로의 데스크톱/390에서 확인했다. 원문 정보·축소하지 않는 팝업·Tab focus trap·ESC 카드 focus 복귀 유지. HTML/React의 1Depth 링크 설정과 실제 Enter navigation 확인.
|
||||
- **PASS**: 같은 Core의 카드 Typography·위치·크기가 네 실행 경로에서 동일하다. Storybook 사용자 메뉴는 기존 세 개다. Core/Wrapper/Storybook 공개 계약·메뉴를 추가하지 않았다.
|
||||
- **PASS**: [정책 보완 13/13](../../test-results/org-chart-p3.2/policy-browser.json). 초기 검사 도구의 배경/화살표 selector는 빈 shell/잘못된 attribute를 측정했다. 구현은 그대로 두고 selector만 바로잡아 readOnly·collapse 0·#F5F8FB·연결 path 5·협업 arrow 5를 네 경로의 1920에서 별도 확인했다. 통과한 431건은 다시 실행하지 않았다.
|
||||
- **PASS**: Core/Wrapper Typecheck, Core ESM/IIFE/타입/CSS Build, Wrapper Build, HTML prepare, React 소비 production Build. 로그는 test-results/org-chart-p3.2/의 typecheck-*.log/build-*.log/prepare-html.log. 기존 inline image chunk 약 2.54MB의 Vite 경고는 유지된다. 신규 브라우저 console/runtime 오류 **0건**.
|
||||
- **PASS**: 구현 세 파일의 before 대비 코드 검토에서 추가 수정 필요 사항 없음. 실제 [1920 Storybook](../../test-results/org-chart-p3.2/storybook-1920x1080.png), [1920 React](../../test-results/org-chart-p3.2/react-1920x1080.png), [390 Storybook](../../test-results/org-chart-p3.2/storybook-390x844.png) 육안 확인.
|
||||
- **PASS**: 보존 스냅샷의 기존 **266개 중 260개 동일**, 예정한 **6개만 변경**. Workdesk **45개 모두 동일**, 기존 P3.1 증거 **115개 모두 동일**. JSON·직원 41명·원본 자산 보존. [변경 전 스냅샷](../../test-results/org-chart-p3.2/baseline-preservation.json), [최종 보존 결과](../../test-results/org-chart-p3.2/source-changes.json).
|
||||
- **PARTIAL / NOT VERIFIED**: [PARTIAL Guard 원본](../../test-results/org-chart-p3.2/guard-react.json)은 staticStatus **NOT APPLICABLE**, status **NOT VERIFIED**, RENDER-002를 유지한다. p1 React bootstrap 소비 graph 검사이며 내부 Core Shadow CSS의 전체 정적 인증을 의미하지 않는다. 원본/결합 CSS는 **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED**. [별도 Runtime](../../test-results/org-chart-p3.2/runtime-evidence.json) 및 [결합 증거](../../test-results/org-chart-p3.2/linked-evidence.json)의 runtimeStatus는 **PASS**, 이름 있는 computed CSS 측정은 PASS다. 원본 미검증을 덮어쓰지 않았다.
|
||||
- **FAIL**: 최종 요청 범위에서 **0건**. 최초 baseline의 Storybook 연결 실패는 sandbox/server 실행 환경 차이였다. 서버 실행 환경을 맞춘 뒤 baseline 20개 capture를 완료했다. [초기 진단](../../test-results/org-chart-p3.2/baseline-browser-initial-server-isolation.json)은 보존했다.
|
||||
|
||||
## 미해결·검증 한계
|
||||
|
||||
요청한 fixture의 1.5배 크기 제한이나 구현 미해결 사항은 없다. 다만 임의의 새 데이터/외부 Theme/다른 브라우저, 운영 Workdesk 전체 픽셀 일치, 전체 Storybook production Build, 스크린리더 발화·실물 touch는 **NOT VERIFIED**다. 기존 P3.1 전체 486건을 현재 결과로 재인증하지 않았다.
|
||||
|
||||
PNG alpha bounding box와 텍스트 Range/프로필의 교차는 검사했다. Noto Sans KR의 인접 행 Range 외곽은 line-height보다 커 겹칠 수 있으므로 이를 곧바로 글자 충돌로 판정하지 않았다. 실제 글리프 ink 간 교차를 자동 인증한 것은 아니며 이 항목은 **NOT VERIFIED**다. 대표 화면은 육안으로 읽힘·로고/글자 분리를 확인했다.
|
||||
|
||||
## 작업 시간·Git
|
||||
|
||||
- Started At: 2026-10-12 16:34:08 KST
|
||||
- Completed At: 2026-10-12 16:51:16 KST
|
||||
- Actual Duration: 17분 8초
|
||||
- Estimated Duration: 25분
|
||||
- Variance: 7분 52초 단축
|
||||
- 작업 폴더: D:\디자인시스템\HMDesign-OrgChart
|
||||
- 브랜치: feature/orgchart-package
|
||||
- 시작/완료 HEAD: d18e3c33801e165e17b6d971e2f6198eed483c3c (동일)
|
||||
- 원본 Workdesk·운영 HMDesign main 미수정. Stage·Commit·Push·Merge·Reset·Rebase 없음.
|
||||
@@ -0,0 +1,54 @@
|
||||
# HMDesign OrgChart P3 — 디자인 및 조회 기능
|
||||
|
||||
RESULT: **PASS — 보존된 41명 fixture와 P3 변경 범위**.
|
||||
|
||||
HTML·직접 열기 classic·React·실제 Storybook에서 P3 검사 **732/732 PASS**, 신규 브라우저 오류 **0**. 기존 P2·P1 검사를 재실행하거나 기대값을 바꾸지 않았다. Guard의 원본 미검증은 별도로 보존한다.
|
||||
|
||||
1. **P2 대비 수정**: 조회 배경, 하위 연결선, 이름/직위 표시, 직책 뱃지, 타이틀 간격, 하위 조직 제목, 허브, 사진 슬롯, 상태 카드, 조직별 교차표, 관계사 바, 링크, 정보 팝업, 컨테이너 높이 맞춤 및 Storybook 메뉴를 수정했다. 조직 관계·순서·41명 데이터와 승인 자산은 유지했다.
|
||||
2. **Workdesk 반영**: 읽기 전용 원본 `D:\디자인시스템\gpd-workdesk\sample\조직도_v1.3`의 실제 HTML/CSS/SVG를 확인했다. 배경 `#F5F8FB`, 하위 타이틀의 크기·그라데이션·그림자·글꼴 수치, 직책 뱃지의 팀별 색, 허브 SVG/텍스트, 통계 표 및 관계사 바를 반영했다. [원본 수치·경로 증거](../../test-results/org-chart-p3/workdesk-summary-source-evidence.json)를 보존했다.
|
||||
3. **카드·타이틀·뱃지·허브**: 대표자 146×74, 1Depth 조직장 146×68, 2Depth 조직장/일반 직원 108×52px 유지. 조회 이름은 `홍 길 동`, 직위와 직책은 원본 문자열이다. 직위와 직책 뱃지를 한 줄에 gap 4px로 놓고 이름과의 gap 0px 유지. 모든 타이틀↔첫 카드 gap 8px. 하위 제목 표면은 114×30.4px이며 부모 팀 색을 상속한다. 허브는 원본 말풍선 SVG, 아이콘 20px/gap 8px, 텍스트 21px/750/24px. 대표자 및 긴 디비전장 뱃지는 동일한 22px 행 내부 하단 정렬로 승인 SVG 우상단 로고와의 충돌을 피했다.
|
||||
4. **연결선**: 조회 전용에서 엔진의 하위 pseudo 연결선을 숨겼다. 기존 대표자↔허브↔주요 조직 연결 SVG Path 5개, 협업 화살표 5개 및 장식은 유지했다. interactive 연결선·접기 동작은 유지했다. 선을 지우거나 조직 관계를 바꾸지 않았다.
|
||||
5. **사진/상태**: 사진 없음은 비원형의 빈 직사각형 슬롯으로 표시한다. 실제 직원 사진이나 임의 인물 이미지를 사용하지 않았다. 기존 승인 카드의 예약 너비 36/32/24px를 유지하고 높이를 62/56/44px로 조정했다. 조회 compact 파견·휴직 카드의 이름 하단 상태 문구만 숨기고 그룹 제목과 원본 상태 데이터는 유지한다.
|
||||
6. **조직별 표 데이터**: 원본 employees에서 `조직 × 직위`를 계산한다. 최상위 직접 소속 1명과 주요 6개 조직의 하위 포함 집계를 분리해 중복 없이 41명. 행 소계는 총괄기획실 1, 사업관리 2, 경영기획 7, 인재성장 7, 통합시스템 10, 디자인기획 10, 솔루션영업 4. 직위 합계는 경영진 1/수석 6/책임 12/선임 11/연구 11. 독립 PositionSummary 공개 API는 유지한다.
|
||||
7. **관계사 집계**: 기존 회사 로고를 사용한 제목 없는 한 줄 바. 한맥 11, 삼안 11, 장헌 0, 장헌산업 0, 피티씨 9, 한라 0, 바론 10, 총 41명. 바 높이 약 55.4px, 로고 39.3px, 글꼴 19.6px 및 원본 간격·그림자 반영. DOM의 43개 카드는 상태 요약 2개 중복 표시를 포함하며 직원 수는 41명이다.
|
||||
8. **1Depth 링크**: 공개 `organizationLinks?: Readonly<Record<string, string | undefined>>` 옵션을 Core와 React에 추가했다. 원본 조상 관계가 1Depth이며 URL이 지정된 제목만 링크다. 미지정 제목은 일반 제목. HTTP(S)/상대 URL을 지원하며 다른 scheme·credentials는 거부한다. 기본 실제 URL은 넣지 않았다. 검증에서만 로컬 증명 경로를 사용해 실제 이동·키보드·focus 및 카드 이벤트 분리를 확인했다.
|
||||
9. **직원 팝업**: 대표자/1Depth/2Depth/일반/파견/휴직 카드 click·Enter·Space로 조회한다. 기존 데이터의 이름·직위·소속·직책·관계사·상태만 표시한다. 닫기/ESC/focus trap/원래 카드 focus 복귀 확인. 팝업은 Stage 밖 native dialog top layer로 표시되며 transform 없음, 데스크톱 폭 420px/390px 화면 폭 358px. 공개 `onEmployeeClick(employee, context)` 지정 시 기본 팝업 대신 호출한다. React callback의 최신 값과 동시 이벤트를 검증했다. 기존 tooltip은 팝업 직전 정리한다.
|
||||
10. **Scale/Overflow**: 기준 디자인 1920×914.5px. 실제 host·조상 높이·padding·max-height와 viewport를 사용하며 임의 Header 48px 차감 없음. 네 경로 모두 같은 측정값이며 문서 client/scroll 폭·높이 동일, 카드/주요 영역 사각형 충돌 및 Stage 밖 콘텐츠 0건.
|
||||
|
||||
- 1920×1080: scale 0.9833333333, Stage 1888×899.2584px, 문서 1920×1080.
|
||||
- 1440×900: scale 0.7333333333, Stage 1408×670.6334px, 문서 1440×900.
|
||||
- 1280×800: scale 0.6500000000, Stage 1248×594.4250px, 문서 1280×800.
|
||||
- 768×1024: scale 0.3833333333, Stage 736×350.5583px, 문서 768×1024.
|
||||
- 390×844: scale 0.1864583333, Stage 358×170.5161px, 문서 390×844.
|
||||
- 추가 높이 조건: 1920×650 scale 0.6754098256, Stage 높이 617.6623px. 고정/max-height 400px host scale 0.4371584574, Stage 높이 399.7814px. 재부착된 400px host의 외부 20px/내부 12px padding에서 가용 높이 336px, scale 0.3672130903, Stage 높이 335.8164px.
|
||||
|
||||
Stage의 layout scroll extent는 transform 전 1920×915px로 남는다. 실제 변환된 모든 콘텐츠 경계가 가용 영역 안에 들어오는 것을 별도로 확인했으므로 `overflow:hidden`만으로 잘림을 숨긴 결과가 아니다. offsetHeight 915는 CSS 높이 914.5의 정수 반올림이며 높이 계산은 소폭 안전 여유를 둔다.
|
||||
|
||||
11. **Storybook**: 사용자 메뉴는 `Logo · Personnel Summary` / `Department Colors` / `Full Composition` 3개. 통합 페이지에서 기존 Logo·PositionSummary·AffiliationSummary Core를 각각 렌더링한다. 기존 독립 검증 Story는 internal로 보존했다. 실제 6041 index·manager·페이지를 확인했으며 메뉴 통합과 Full Composition 배치는 분리했다.
|
||||
12. **수정 파일**: 기존 소스/문서 27개와 신규 구현·검사·보고 파일은 아래 목록. 재생성된 dist 및 정확한 시작 해시 차이는 [전체 파일 기록](../../test-results/org-chart-p3/source-changes.json)에 있다. 기존 package.json/package-lock.json의 미커밋 변경은 추가로 수정하지 않았다.
|
||||
|
||||
- Core: `packages/org-chart/src/core/{employee-card,status-summary,render,types,index,org-chart-content,organization-totals,affiliation-bar}.ts`, `src/index.ts`, `src/styles/{employee-card,org-chart-readonly,org-chart}.css`, `build.mjs`, `README.md`.
|
||||
- 신규 Core: `packages/org-chart/src/core/employee-info-dialog.ts`, `src/styles/employee-info-dialog.css`, `src/styles/org-chart-p3-summary.css`.
|
||||
- React: `packages/org-chart-react/src/{OrgChart.tsx,types.ts}`, `README.md`.
|
||||
- 예제: `examples/org-chart/{p1-app.js,p1.css}`, 신규 `verify-p3.mjs`; `examples/org-chart-react/{p1.tsx,p1.css,README.md}`.
|
||||
- Story: `src/org-chart/{org-chart-p1.stories.tsx,org-chart-stories.css}`.
|
||||
- 문서: `docs/org-chart/{integration,ui-composition,card-design-stage2}.md` 및 이 P3 보고서. P1/P2 보고서는 수정하지 않았다.
|
||||
- 산출물: Core/Wrapper/HTML/React dist, `test-results/org-chart-p3`의 브라우저 JSON·원본 Guard·별도 runtime/linked evidence·진단 이력·캡처·시작/변경 해시. `assemble-evidence.mjs`는 저장된 증거만 연결하며 테스트를 재실행하지 않는다.
|
||||
|
||||
13. **HTML·React 검증**: Core/Wrapper typecheck, Core ESM/IIFE/타입/CSS 및 Wrapper build, HTML prepare, React 소비 예제 production build PASS. 공통 Core의 geometry·표·카드가 HTML HTTP/file classic/React HTTP/Storybook에서 일치했다. Chrome 155.0.8059.39의 5개 화면, 링크/callback/키보드, 6종 카드×2화면×4경로 팝업 48개 측정, 높이 제한 조건을 포함해 **732/732 PASS**. 원본 이미지 inline 때문에 React 약 2.54MB chunk 경고는 유지한다. 실제 테스트는 live Storybook 6041이며 오래된 `storybook-static` 결과를 최종 근거로 사용하지 않는다.
|
||||
14. **기존 PASS/신규 FAIL**: 승인 SVG·로고·장식·41명 fixture·P1/P2 보고서·기존 검사 등 보존 대상 38개 해시 동일. 관련 interactive/독립 API/공통 Core 회귀는 P3 검사에서 PASS. P2 484건과 오래된 verify/verify-p1 suite 전체는 재실행하지 않았으므로 전체 기존 PASS를 재인증하지 않는다. 알려진 과거 FAIL 기록·기대값을 그대로 보존했다. 초기 P3 522건/63 FAIL과 중간 554건/10 FAIL JSON도 보존했으며 최종 신규 FAIL 0. 제품의 focus 복귀/뱃지 겹침/재부착 host padding 오류 및 검사 환경의 오래된 Storybook 경로·선택자·Chrome 0px letter-spacing 직렬화 근거를 수정했다. 실패를 필터링하지 않았다.
|
||||
15. **남은 시각적 차이**: 원본 Pretendard Variable 대신 기존 승인 글꼴을 유지한다. 원본 사진 55×76/54×70/38×52는 승인 카드보다 커서 그대로 복사하지 않고 슬롯 크기를 맞췄다. 긴 뱃지 행 정렬은 로고 충돌 방지를 위한 조정이다. 표의 실제 열/행은 현재 fixture를 기반으로 하며 원본 API의 직위 목록은 제공되지 않았다. 긴 `경영진`/`통합시스템 Div` 줄바꿈으로 표 높이는 약 200.375px. 바 폭은 기준 좌표에 맞춰 1806px(원본 약 1805.7px). 최종 Storybook 1920/390 및 HTML 1920 캡처를 육안 확인했다. 모바일은 전체 화면 비례축소로 글자가 작지만 정보 팝업은 축소하지 않는다.
|
||||
16. **Guard/NOT VERIFIED**: PARTIAL Guard를 최종 React bootstrap에 1회 실행. 원본 staticStatus `NOT APPLICABLE`, status `NOT VERIFIED`, `RENDER-002` 보존. 원본 CSS STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED, checks `[]`. [별도 runtime](../../test-results/org-chart-p3/runtime-evidence.json)은 동일 소스·계약 fingerprint와 브라우저 증거 SHA로 연결했다. [결합 증거](../../test-results/org-chart-p3/linked-evidence.json)는 Runtime PASS·명시한 computed CSS PASS이나 Guard의 정적 적용 범위/스타일 미검증 때문에 전체 status NOT VERIFIED를 유지한다. Core Shadow CSS 전체 Guard 인증, 실제 운영 Workdesk 픽셀 일치, 자동 glyph/곡선 교차 검사, 모든 임의 데이터/테마/브라우저, screen reader 발화·실물 touch, 전체 Storybook production build는 NOT VERIFIED.
|
||||
17. **Git/원본 보존**: `D:\디자인시스템\HMDesign-OrgChart`, `feature/orgchart-package`, HEAD `d18e3c33801e165e17b6d971e2f6198eed483c3c`. Workdesk 원본과 HMDesign main을 수정하지 않았다. 기존 미커밋 작업을 보존하고 Stage·Commit·Push·Merge·Reset·Rebase를 수행하지 않았다.
|
||||
|
||||
검증 근거: [최종 browser JSON](../../test-results/org-chart-p3/browser.json), [1920 캡처](../../test-results/org-chart-p3/storybook-1920x1080.png), [390 캡처](../../test-results/org-chart-p3/storybook-390x844.png), [원본 Guard](../../test-results/org-chart-p3/guard-react.json), [초기 진단](../../test-results/org-chart-p3/browser-initial.json), [중간 진단](../../test-results/org-chart-p3/browser-after-card-fixes.json).
|
||||
|
||||
미리보기: [Storybook Full Composition](http://127.0.0.1:6041/?path=/story/orgchart--full-composition).
|
||||
|
||||
작업 시간:
|
||||
|
||||
- Started At: 2026-10-12 15:19:27 KST
|
||||
- Completed At: 2026-10-12 15:56:54 KST
|
||||
- Actual Duration: 37분 27초
|
||||
- Estimated Duration: 45~60분
|
||||
- Variance: 예상보다 7분 33초~22분 33초 단축
|
||||
@@ -0,0 +1,72 @@
|
||||
# HMDesign OrgChart v0.1 구현·최종 검증 보고
|
||||
|
||||
RESULT: **PASS** — 이번 요청의 Core/JSON/Vanilla 최소 완료 조건 12개 충족. React Wrapper·Storybook 및 미검증 후속 항목을 포함한 전체 MVP 완료 판정은 아닙니다.
|
||||
|
||||
## 개발 환경과 보존
|
||||
|
||||
- 작업 경로: `D:\디자인시스템\HMDesign-OrgChart`
|
||||
- 브랜치: `feature/orgchart-package`
|
||||
- 작업 전/후 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c` (동일)
|
||||
- origin(fetch/push): `https://gitea.hmac.kr/b25027/HMDesign.git`
|
||||
- Local main, 공식 원격 main/Feature도 위 SHA 그대로. 운영 `D:\디자인시스템\HMDesign` Working Tree 깨끗함을 작업 전/후 읽기 전용으로 확인.
|
||||
- 시작 시 기존 미추적 파일: `docs/org-chart/*.md` 7개. 모두 SHA-256 일치 확인(7/7); 삭제·초기화·전면 수정 없음.
|
||||
- 기존 `packages/ui`, `packages/center-graphic`, Theme/Token/Storybook/AGENTS 및 Spike 수정 없음. 최종 tracked diff는 루트 package.json·package-lock.json뿐.
|
||||
- Stage/Commit/Push/Merge 없음. Staged diff 비어 있음. main/운영 프로젝트 수정 없음.
|
||||
- 수행 방식: Track A(Data), Track B(Core/CSS) 병렬 구현 + 읽기 전용 검토 작업자. Main만 package/export/build/examples와 최종 통합을 수정·관리함.
|
||||
|
||||
## 구현 결과
|
||||
|
||||
1. 신규 패키지: `packages/org-chart`, `@hm-design/org-chart@0.1.0`. React/React DOM peer·런타임 의존성 없음.
|
||||
2. 직접 의존성: `orgchart@6.0.0` 고정. upstream 전이 의존성 `html2canvas@1.4.1`, `json-digger@2.0.2`, `jspdf@4.2.1` 및 그 의존성이 lockfile에 추가됨. OrgChart 외 무관한 직접 의존성 추가 없음. export UI는 비활성화하며 export 라이브러리는 브라우저 번들에 포함되지 않음. upstream MIT 고지를 배포 산출물에 포함.
|
||||
3. Data Contract: 조직/직원/관계사를 ID로 분리하고 문서의 `displayOrder/jobTitle/profileImage/workStatus` 유지. 버전은 문서 초안의 `0.1-draft`. 최신 요청에 따른 최상위 schemaVersion 필수 추가; metadata.schemaVersion은 선택 호환 mirror로 일치 검사. relations/presentation 및 일부 metadata 확장 필드는 선택. 조직·직원 ID namespace는 독립.
|
||||
4. JSON Source: 객체 또는 주입 URL, 선택 fetch를 지원. 객체는 생성 시 캡처하고 load마다 독립 불변 snapshot 반환. HTTP/JSON/취소 오류를 구별하고 서버 원문을 노출하지 않음. 기본 native fetch를 globalThis에 바인딩. 외부 JSON은 HTTP에서 검증; file:// 예제는 inline JSON 사용.
|
||||
5. Validation: 필수 값·타입·버전·날짜·중복 ID·참조·parent cycle·leader/관계사·상태·관계·presentation·위험한 HTML/이미지 URL 검증. 구조화된 code/path/message 반환. 외부 문자열은 textContent로 렌더링하고 engine ID는 내부 prefix+숫자로 생성.
|
||||
6. Summary: 고유 직원 ID 기준 raw total, 조직 direct/inclusive, 직위·관계사·상태 집계. 조직 이동/추가/삭제와 표시 옵션 독립성 테스트. 업무상 총원 포함 정책을 기본값으로 숨기지 않으며 미주입은 policyRequired, 미지원 정책은 unsupportedPolicy. 지원 정책 범위는 명시적 primaryOnly·leader 포함 및 유효 상태 목록. raw 숫자는 승인된 재직 총원이 아님.
|
||||
7. Core: createOrgChart, mount(container?), update, reload(source?), destroy, subscribe, getState. 마지막 시작 revision 우선·AbortSignal·immutable 입력·최종 commit 검사·재진입 callback 방어. mount 중 CSS load/error를 확인. 요청 및 data 실패는 error state, 잘못된 호출 순서는 사용 오류. destroy는 동기·반복 안전·terminal.
|
||||
8. Node UI: 조직·부서, 조직장·직원, 이름·직위·직책·상태, 사진/fallback, 원본 조직별 인원. 계층 연결선과 native 버튼 키보드 확장/축소. 여러 root는 독립 엔진으로 실제 조직 관계를 보존. 갱신 시 확장 상태는 초기화; 제거된 focus는 viewport로 복귀.
|
||||
9. Theme: 호스트의 기존 HM Theme 변수/폰트를 상속. node와 연결선은 기존 Semantic 변수, radius/font 사용. Shadow DOM 안에 upstream engine.css와 전용 style.css를 로드하여 전역 selector 누출 방지. 예제 Theme 산출물은 실제 공개 CSS Entry에서 생성하고 기존 폰트/OFL을 포함; 독립 유지되는 Theme 복제본 없음.
|
||||
10. Export: ESM index.js + index.d.ts, style.css, engine.css, classic browser subpath. IIFE는 window.HMOrgChart. 브라우저 번들에 unresolved bare import 없음. stylesheets의 실제 URL을 호스트가 stylesheetUrls로 주입해야 함.
|
||||
11. 예제: `examples/org-chart/dist/index.html`(HTTP ESM/외부 JSON), `classic.html`(직접 열기/Classic inline JSON). build outputs/public Entry만 소비하며 Spike 코드 참조 없음. reload·이동/추가 update·destroy·새 instance 생성 controls. 가상 조직·직원만 사용.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- **PASS** TypeScript: `npm run typecheck --workspaces --if-present` — OrgChart/UI/Center Graphic 모두 통과. root 앱 production build를 실행한 결과로 해석하지 않음.
|
||||
- **PASS** Package Build: ESM 123.50 kB(gzip 29.36), IIFE 126.34 kB(gzip 29.55), 선언/CSS/license 생성.
|
||||
- **PASS** 데이터 테스트 22/22: 계약/참조/순환/URL/정책/추가·삭제·이동/중복 방지/불변성/취소·늦은 응답/native fetch receiver 회귀.
|
||||
- **PASS** Chrome 155.0.8059.39 실제 브라우저 검사 69/69: HTTP ESM + file:// Classic 각각 mount/custom node/summary/reload/update; 네 너비 document overflow·내부 scroll geometry·HM color/font/radius·1줄 ID·Theme 연결선 정렬; keyboard Enter/Space·focus 복귀; depth-3 확장 state; 두 instance/Scoped Theme; source/validation error·latest revision·reentrant destroy·late callback 차단; 5회 mount/reload/destroy 자원 안정성. Console/runtime errors 0.
|
||||
- **PASS** Destroy 관찰 범위: 전용 DOM 제거·호스트 sibling과 다른 instance 보존·chart observer 0·전역 listener 수 안정·요청 abort·destroy 이후 callback 0. Playwright 자체 observer/listener를 chart 자원과 구분함. 전체 heap 회수를 판정한 결과가 아님.
|
||||
- **PASS** npm tarball consumer: 실제 tarball 추출, DOM 없는 public ESM import, public CSS/browser export, 엄격한 NodeNext 타입 소비(React types 없음), fixture validation/summary. React가 미설치된 사용자 npm 환경에서 새로 dependency install하는 테스트까지 수행한 것은 아님; HTML은 React를 로드하지 않음.
|
||||
- **PASS** 기존 main/설계 문서/패키지 소스 보존, Git diff --check, 문서 링크 검사(119 files/620 links/problems 0).
|
||||
- **NOT VERIFIED** AI Guard 원본 status/staticStatus/runtimeStatus. 1회 PARTIAL 실행에서 ENTRY-001/SCOPE-001, checkedFiles=[]: Vanilla package가 React consumer graph 전제에 포함되지 않음. Guard/Theme/Component 규칙 수정 없음. 원본 styleEvidence는 STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED, checks=[]이며 비어 있는 검사 PASS를 디자인 승인으로 사용하지 않음.
|
||||
- 별도 CSS 증거: 실제 선언 존재 확인 PASS, 위 제한된 browser computed·overflow/interaction PASS. **DESIGN_INTENT와 FINAL은 NOT VERIFIED**: reference 디자인 승인·전체 시각 parity를 주장하지 않음.
|
||||
|
||||
검증 증거: `test-results/org-chart-browser/report.json` 및 HTTP/file:// 네 너비 PNG, `test-results/org-chart-consumer/report.json`, `test-results/org-chart-preservation.json`, `test-results/org-chart-pack.json`. 이 생성물은 Git ignored이며 source test harness는 보존함.
|
||||
|
||||
## 변경 내역
|
||||
|
||||
- 수정: `package.json`(6개 OrgChart 실행 scripts), `package-lock.json`(workspace link와 upstream dependency graph). 이미 등록된 packages/* workspace는 변경하지 않음. 기존 @babel/runtime의 dev 표시 제거는 새 production 전이 dependency 영향이며 버전 교체가 아님.
|
||||
- 신규 패키지: package.json, README.md, tsconfig.json, tsconfig.build.json, build.mjs, test.mjs, src/index.ts; data/{types,index,validate,normalize,summary,snapshot,source,data.test}.ts; core/{types,index,engine,render}.ts, core/vendor.d.ts; styles/org-chart.css.
|
||||
- 신규 예제: index.html, classic.html, app.js, demo.css, data-v1.json, data-v2.json, prepare.mjs, serve.mjs, verify.mjs, verify-package.mjs, create-fixtures.mjs.
|
||||
- 신규 보고서: 이 파일. 기존 7개 설계 문서는 수정하지 않음.
|
||||
- dependency install은 초기 sandbox DNS 실패 후 승인된 실행으로 완료. npm cleanup에서 ignored node_modules 일부 EPERM 경고가 있었으나 설치 exit 0이며 후속 typecheck/build/runtime 모두 통과.
|
||||
- 공식 tag 웹 조회는 실패했으므로 npm 6.0.0 배포물과 특정 upstream git tag의 byte 동일성은 **NOT VERIFIED**. 설치 manifest와 lock integrity를 기준으로 버전을 고정함.
|
||||
|
||||
## 설계 정합성과 후속 작업
|
||||
|
||||
- Architecture/Framework Integration의 공통 데이터→검증→ViewModel→engine→DOM 및 terminal lifecycle 유지. 공개 target snippet에 필요한 CSS URL 계약 추가와 schemaVersion 위치 변화는 위에 명시. 기존 문서의 구현 전/미착수 상태는 과거 기록으로 보존; 신규 package README/이 보고서가 현재 실행 계약.
|
||||
- 다중 root는 임의 parent 없이 forest 렌더링. leader cross-organization은 정책 미정으로 LEADER_APPROVAL_REQUIRED. 조직 0개는 오류, 직원 0명은 valid ready.
|
||||
- hubText/showDispatched/showOnLeave는 reserved 필드이며 별도 허브/상태 영역 UI는 이번 최소 범위에서 구현하지 않음. showEmployees/showSummary/showAffiliations는 실제 지원. 추가 관계는 검증하고 계층선 미포함 안내만 표시; 복잡한 relation overlay 제외.
|
||||
- pan/zoom/minimap/drag/export 비활성화. 390px은 읽을 수 있는 원래 크기와 내부 가로 스크롤로 표시; 고급 반응형 처리·확장/scroll state 보존 제외.
|
||||
- React Wrapper는 ref/effect·Props/Source→동일 Core·unmount cleanup만 연결. Validation/집계/engine/node/style 중복 구현 금지. Storybook은 이후 가상 fixture·error/loading·policy·Theme scope·lifecycle 콘텐츠 Story로 구성.
|
||||
- 기술적 리스크: 6.0.0 내부 init observer hook에 의존하는 managed subclass. 버전 upgrade 전 재검토 필요. upstream anonymous node/transition 이벤트는 제거된 DOM과 함께 GC 가능한 참조가 되며 완전 GC·heap 회수는 **NOT VERIFIED**. 매우 큰 데이터의 성능/깊이 상한·보안 CSP/origin 정책·실제 API/auth·screen reader announcements·reference 시각 일치·Theme 미연결 동작·React runtime은 **NOT VERIFIED**.
|
||||
- Commit/Push/Merge는 요청대로 보류. 이후 사용자 승인 없이 수행하지 않음.
|
||||
|
||||
## 시간 기록 (Asia/Seoul)
|
||||
|
||||
- Started At: 2026-10-12 10:00:28 KST
|
||||
- Completed At: 2026-10-12 10:19:19 KST
|
||||
- Actual Duration: 18분 51초
|
||||
- Estimated Duration: 최초 약 2~3시간
|
||||
- Variance: 예상 하한 2시간 대비 101분 9초 단축; 예상 상한 3시간 대비 161분 9초 단축
|
||||
|
||||
예상은 참고값입니다. 기존 PoC/설치 환경 재사용과 병렬 구현으로 실제 시간이 줄었으며 미검증 항목을 PASS로 대체하지 않았습니다.
|
||||
@@ -0,0 +1,91 @@
|
||||
# OrgChart Integration
|
||||
|
||||
상태: 공식 문서·소스 읽기 기반 기술 검토. 설치·실행·번들·브라우저 PoC는 수행하지 않았다. 모든 지원 완료 판정은 **NOT VERIFIED**다. 최신 범위는 Vanilla HTML/JavaScript + React이며 [Framework Integration](framework-integration.md)에서 같은 Core를 연결한다.
|
||||
|
||||
## 1. 엔진 버전·라이선스·근거
|
||||
|
||||
확정 엔진은 [dabeng/OrgChart](https://github.com/dabeng/OrgChart)다. 별도 프로젝트 `react-orgchart`를 채택한 것으로 해석하지 않는다. 확인일은 2026-10-12(KST)이며 웹 자료는 이후 변경될 수 있다.
|
||||
|
||||
- [공식 릴리스 목록](https://github.com/dabeng/OrgChart/releases)은 6.0.0을 최신으로 표시하고 v6부터 native JavaScript로 전환하여 jQuery 의존성을 제거했다고 설명한다. **6.0.0을 우선 검증 후보로 제안**하되 지원/고정 버전 확정은 아니다. 구버전 jQuery 연동 예제를 그대로 사용하지 않는다.
|
||||
- 확인한 [master package.json](https://raw.githubusercontent.com/dabeng/OrgChart/master/package.json)의 version은 6.0.0, main은 `dist/js/orgchart.min.js`, dependencies는 html2canvas/json-digger/jspdf다. 이 의존성을 Core의 필수 기능 또는 배포물에서 자동 제외 가능하다고 단정하지 않는다. Export/download는 요청되지 않았으므로 UI에는 추가하지 않으며 transitive bundle 영향은 조사한다.
|
||||
- [공식 LICENSE](https://raw.githubusercontent.com/dabeng/OrgChart/master/LICENSE)와 manifest는 MIT를 표시한다. 엔진 재배포 시 고지·라이선스 포함을 계획하고 실제 배포 artifact의 라이선스와 transitive 의존성은 별도로 확인한다.
|
||||
- [README](https://github.com/dabeng/OrgChart#readme), [JS 소스](https://raw.githubusercontent.com/dabeng/OrgChart/master/src/js/orgchart.js), [CSS 소스](https://raw.githubusercontent.com/dabeng/OrgChart/master/src/css/orgchart.css), [데모 index](https://dabeng.github.io/OrgChart/)를 읽었다. 데모를 브라우저에서 실행하거나 화면을 대조하지 않았다.
|
||||
- v6.0.0 tag의 raw 파일 조회와 release API 조회는 도구에서 실패했다. 따라서 **master 소스와 고정 tag/npm 배포물의 동일성은 NOT VERIFIED**다. 구현 전에 배포된 버전·tag·소스 hash·라이선스를 다시 대조하고 설치 후보를 고정한다. npm registry 배포/ESM entry/type 선언 존재도 아직 확인하지 않았다.
|
||||
|
||||
## 2. 기능·구조 검토와 책임 분리
|
||||
|
||||
아래는 upstream 문서/소스에 기능 경로가 있음을 확인한 결과이며 HMDesign에서의 동작 PASS가 아니다.
|
||||
|
||||
- **데이터·계층**: README는 JSON/UL 데이터와 `children` 기반 계층을 설명한다. Core의 flat ID 계약을 Engine Adapter가 파생 트리로 변환한다. engine에 UI 데이터를 원본 그대로 넘기지 않는다.
|
||||
- **커스텀 노드**: `nodeTemplate(data)`와 `createNode(node, data)` 경로가 있다. 읽은 master는 template 반환값을 문자열 결합하므로 Element 반환 지원을 가정하지 않는다. **정적 skeleton 문자열 + createNode callback의 DOM/textContent 구성**을 우선 검토한다. engine edge/계층을 지우지 않고 콘텐츠 전용 자식만 관리한다. 고정 버전의 callback 순서·크기 측정·확장/축소는 PoC로 확인하며 React root를 노드 안에 만들지 않는다.
|
||||
- **확장·축소**: show/hide parent/children/siblings 계열 API가 문서화돼 있다. 깊은 조직·custom 노드·focus·키보드와 함께 검증한다.
|
||||
- **pan·zoom**: pan/zoom과 한계 옵션이 있다. CSS transform·touch·wheel·호스트 스크롤·Overlay 좌표 결합은 미검증이다. 기능 노출과 설정 기본값은 PoC 후 확정한다.
|
||||
- **가로·세로 배치**: direction/verticalLevel/hybrid/compact 관련 문서가 있다. 일부 조합 제약이 안내되므로 모든 조합 지원으로 쓰지 않는다. 가로/세로 스크롤은 콘텐츠 컨테이너 책임과 함께 검사한다.
|
||||
- **데이터 갱신**: `init`/`setOptions`의 데이터 교체 경로가 있다. 일관성·성능·확장/zoom 상태 유지까지 보장된 것은 아니다.
|
||||
|
||||
JS 소스의 init은 기존 chart DOM을 제거·재생성하고 node 생성은 입력에 parentId를 추가하는 경로가 있다. 템플릿 문자열은 `insertAdjacentHTML` 경로로 삽입되므로 외부 텍스트 직접 주입은 금지한다. Core가 복사한 engine 데이터와 안전한 텍스트/ID를 제공해야 한다. [소스 근거](https://raw.githubusercontent.com/dabeng/OrgChart/master/src/js/orgchart.js).
|
||||
|
||||
같은 소스에 이벤트 해제 함수와 Observer 사용은 있으나 공개 완결 `destroy()`는 읽은 소스/README에서 확인하지 못했다. Core의 Destroy는 **engine API가 아니라 우리가 보장해야 할 facade 계약**이다. 인스턴스 제거만으로 document 이벤트·Observer가 정리됐다고 주장하지 않는다. 소스 검토에서 관찰된 정리 한계는 최초 PoC의 차단 조건이며 필요하면 engine 버전/기능 제한/명시적 disposal 지원 또는 승인된 별도 통합 대안을 검토한다. 이번에는 upstream/기존 코드를 수정하지 않는다. [JS 검토 근거](https://raw.githubusercontent.com/dabeng/OrgChart/master/src/js/orgchart.js).
|
||||
|
||||
여러 인스턴스는 chartClass 관련 문서만으로 격리를 보장할 수 없다. 컨테이너 Element 전달·인스턴스별 DOM ID·container 내부 query·이벤트·focus·zoom을 실제 검증한다. `.orgchart .node .title/.content` 등 upstream CSS의 고정 크기·색상·generic descendant selector가 custom 콘텐츠와 충돌할 수 있다. engine CSS와 Core CSS를 컨테이너 내부로 제한하고 다른 HM 요소를 비교한다. [CSS 근거](https://raw.githubusercontent.com/dabeng/OrgChart/master/src/css/orgchart.css).
|
||||
|
||||
특수 곡선 연결선·중앙 허브·그룹별 자유 카드 배치·교차 조직 relation을 기본 기능만으로 표현할 수 있다는 근거는 확보하지 못했다. **NOT VERIFIED / 별도 PoC**다. 계층 연결선은 engine, 추가 관계는 Relation Overlay, 허브/summary/상태 그룹은 콘텐츠 Layout의 책임으로 나눠 검토한다. fixed 좌표·engine hierarchy 왜곡으로 reference를 맞추지 않는다.
|
||||
|
||||
update/reload도 cleanup 대상이다. master의 pan/zoom 해제 경로는 현재 chart에 저장된 handler를 조회하므로 init 후 이전 handler를 회수하지 못할 위험이 있다. **이전 chart 참조를 잃기 전에** pan/zoom·minimap·관련 observer와 이벤트를 정리할 수 있는지 PoC로 확인한다. 반복 재생성 후 container/document 이벤트 수·응답 횟수 증가를 검사한다. 소스상 위험이며 실제 누수 재현은 NOT VERIFIED다.
|
||||
|
||||
master는 모듈 평가 시 window/document를 참조하는 경로도 있어 DOM 없는 import 안전성을 주장할 수 없다. Core facade·데이터 계층 import를 engine 로딩에서 분리하고 지연 로딩 또는 승인된 변환 entry를 검증한다. native ESM export가 이미 존재한다고 가정하지 않는다.
|
||||
|
||||
## 3. HM Theme 연결
|
||||
|
||||
Core 노드는 framework와 무관한 동일 HTML/CSS다. React Wrapper가 다른 노드 디자인을 만들지 않는다. Token 정책은 [Runtime Theme](../guidelines/runtime-theme.md), [Token 가이드](../guidelines/테마-토큰-가이드.md), [Runtime Portability](../guidelines/runtime-portability.md)를 따른다.
|
||||
|
||||
확인한 선언은 [default.css](../../packages/ui/src/theme/default.css)의 `--hm-sem-bg-content`, `--hm-sem-fg-primary`, `--hm-sem-fg-secondary`, `--hm-sem-stroke-default`, `--hm-radius-container` 등이다. 노드 surface/상태/연결선의 **최종 매핑은 디자인 승인 전**이며 content 배경 변수를 Card White 면으로 자동 적용하지 않는다. Typography와 spacing은 모든 것이 공개 runtime 입력은 아니다. [card.css](../../packages/ui/src/theme/card.css), [avatar.css](../../packages/ui/src/theme/avatar.css), [table.css](../../packages/ui/src/theme/table.css), [tailwind.css](../../packages/ui/src/tailwind.css)의 실제 규칙을 참고하되 없는 `--hm-spacing-*` 같은 Token을 추측하지 않는다.
|
||||
|
||||
React 호스트는 기존 CSS entry `index.css → hm-theme.css`와 공개 HM CSS 순서를 유지한다. Core CSS는 별도로 연결한다. Vanilla는 browser-ready Theme CSS와 상대 fonts/assets가 필요하며 Tailwind 원본 CSS/bare npm import를 그대로 `<link>`에 넣지 않는다. [Theme-only 배포](../guidelines/theme-delivery.md)의 현재 `theme-dist/themes`는 JSON 산출물이므로 standalone CSS가 이미 제공된다고 오인하지 않는다.
|
||||
|
||||
Core scope는 전용 컨테이너 class 내부로 제한하고 HM 전역 변수 재선언·global reset·HM Component selector override를 금지한다. OrgChart 전용 alias가 필요하면 기존 의미의 변수를 참조하고 scope에서만 선언한다. Theme 없는 환경의 기본 스타일 제공/필수 연결/오류 정책은 TBD다. 임의 fallback 색으로 미정의 Token을 감추지 않는다. 정확한 standalone Theme artifact 경로·포함 범위·CSS import flatten·font 경로는 배포 PoC에서 확정한다.
|
||||
|
||||
Vanilla의 dependency/peer에 `@hm-design/ui` 전체 패키지를 추가해 Theme를 해결하면 React/React DOM/Tailwind peer가 설치 조건에 영향을 줄 수 있다. 승인된 호스트 CSS asset 또는 React peer 없는 Theme 산출 경로를 비교하고 React 미설치 consumer에서 실제 설치·동작을 검증한다. 현재 설치 동작은 NOT VERIFIED다.
|
||||
|
||||
현재 `packages/ui/src/theme.css` 원본은 전체 Component/Pattern CSS import와 절대 `/fonts/` 경로를 포함한다. Core prefix만으로 호스트 Theme까지 scope된다고 주장하지 않는다. Vanilla Theme는 전체 CSS와 필요한 변수·font 제공 범위를 구별하고 상대 font 경로를 확인한다. Core/engine CSS 누출과 호스트 Theme 자체의 전역 영향을 별도로 기록한다.
|
||||
|
||||
Theme 적용/해제의 owner는 호스트다. Core는 제공된 scope를 상속하고 Theme를 자동 주입/reset하지 않는다. 별도 Scoped Theme는 기존 `[data-hm-theme]`에서 mapping을 재계산하는 계약과 Scoped partial의 미지정 값이 HM Default를 쓰는 의미를 따른다. 단순 후손 override로 Global 값을 재계산한 것처럼 간주하지 않는다. Global A + Scoped B + 두 instance의 computed 값·독립성·cleanup을 검증한다.
|
||||
|
||||
## 4. JSON에서 REST API로 전환
|
||||
|
||||
공통 `OrgChartDataSource.load({signal}) → Promise<unknown>`은 normalized 계약 후보를 반환한다. JSON Mock·외부 JSON·API adapter는 모두 같은 validator·ViewModel·집계·Core renderer를 사용한다. [사용 예시](framework-integration.md#3-데이터-source-사용-예시)를 참고한다.
|
||||
|
||||
바뀌는 영역: 호스트의 Source 생성/선택, 실제 API client 설정, 원본 응답 매핑, 취소·권한·이미지 접근·향후 캐시/재시도 정책, Source 계약 테스트. 고정 계약과 다른 응답은 API adapter에서 변환한다.
|
||||
|
||||
유지되는 영역: normalized 필드/ID 계약(승인 버전 기준), runtime Validation, 집계 정책과 공통 함수, engine 변환, Core DOM·노드 디자인·Theme CSS, Core facade, React Wrapper의 Props 구조, 콘텐츠 단독 Story. Core나 Wrapper가 서버 URL/원본 필드에 직접 의존하면 완료 조건 실패다.
|
||||
|
||||
실제 API가 생겨 schema를 확장해야 하면 Source 전환만으로 끝나는 것처럼 표현하지 않고 contract migration을 별도 승인한다. Mock API Story는 지연/오류/응답 경쟁을 흉내내는 가상 Source이며 실제 서버·인증 검증으로 보고하지 않는다.
|
||||
|
||||
여러 endpoint/pagination으로 수집하면 Source Adapter가 완전한 collection과 같은 기준의 스냅샷을 조립해 한 번에 반환한다. 부분 페이지·서로 다른 버전의 조직/직원/관계를 Core에 순차 반영하지 않는다. ID 참조 검사를 통과해도 시간 일관성을 입증한 것은 아니다. 서버 일관성·버전 확인·재시도 방법은 실제 API 명세 전 TBD다.
|
||||
|
||||
## 5. API 전환 전 결정 항목
|
||||
|
||||
실제 명세가 없어 다음은 모두 TBD다: Endpoint·메서드·응답 schema·인증/인가·데이터 권한, 갱신 주기·캐시/만료·재시도/오류 처리, 프로필 이미지 권한/origin/만료, 개인정보 노출/로그/보존 정책, dataVersion·기준일·updatedAt의 의미와 충돌 처리. 임의 회사 주소·토큰·인증 규칙은 예시에 넣지 않는다.
|
||||
|
||||
HTML 환경도 상위 앱 API client가 인증을 담당한다. React 전용 hook/context를 API adapter의 필수 전제로 삼지 않는다. 공유 스냅샷이 같으면 두 환경의 조직 구조·집계·Theme·인터랙션 결과가 같아야 한다.
|
||||
|
||||
## 6. P3 조회 API 추가 — 2026-10-12
|
||||
|
||||
위 초기 기술 검토 기록은 보존한다. 현재 패키지의 추가 API는 [Core README](../../packages/org-chart/README.md#organization-links-and-employee-information-p3)와 [React README](../../packages/org-chart-react/README.md#public-api)에 기술한다. 런타임 검증 결과는 해당 구현 보고서에서 별도로 확인한다.
|
||||
|
||||
- `organizationLinks`는 기존 조직 ID별 외부 설정 URL map이다. 원본 계층의 1Depth 제목에만 적용하며 누락·빈 URL은 일반 제목으로 남는다. 실제 URL을 패키지에서 만들지 않는다. HTTP(S)·상대 URL을 허용하고 다른 scheme·잘못된 URL·credentials는 생성 시 `TypeError`로 거부한다. 링크는 native 키보드 이동·Focus 표시를 지원하며 카드 클릭과 분리된다.
|
||||
- 대표자·조직장·직원·파견·휴직 카드의 클릭·Enter·Space는 기본 조회 dialog를 연다. `readOnly`에서도 조회하며 수정 UI는 없다. 이름·직위·조직·직책·관계사·근무 상태 중 기존 값만 표시하고 없는 선택 필드는 생략한다. readOnly 가상 표시 이름은 카드의 `홍 길 동`과 일치하며 데이터는 변경하지 않는다.
|
||||
- 기본 native modal은 축소 Stage 밖의 같은 Core shell에 위치한다. 닫기 버튼·ESC·Focus trap·카드로 Focus 복귀를 처리하고 정보 팝업을 열면 이름 tooltip을 닫는다. HTML과 React는 같은 구현을 사용한다.
|
||||
- 선택적 `onEmployeeClick(employee, context)`를 제공하면 기본 팝업 대신 호스트 callback을 실행한다. context는 기존 `organization`·`affiliation`, Focus 가능한 `trigger` 카드, `originalEvent`를 담는다. 이 경우 호스트가 팝업 접근성과 정리를 담당한다. 기존 API를 제거하지 않는 추가 옵션이다.
|
||||
- Vanilla는 생성 옵션으로 전달하고 설정을 변경할 때 새 handle을 만든다. React는 같은 이름의 Props로 전달한다. 새 URL map 또는 callback 유무 변경은 재생성하며, callback 함수 identity만 바뀌면 최신 committed callback을 사용한다. 공개 Core `setOptions`는 추가하지 않았다.
|
||||
|
||||
```js
|
||||
const chart = createOrgChart({
|
||||
...configuredBaseOptions,
|
||||
organizationLinks: configuredOrganizationLinks,
|
||||
// 생략하면 Core 기본 조회 팝업 사용
|
||||
onEmployeeClick(employee, context) {
|
||||
openEmployeeInformation(employee, context);
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,163 @@
|
||||
# HMDesign OrgChart P1 — 품질 보완 및 디자인 검수
|
||||
|
||||
RESULT: **PARTIAL**
|
||||
|
||||
이번 변경은 기존 P1의 Gradient 대비 검사, Department Colors, 실제 로고 표시, Guard 원인 분석으로 제한했다. 사용자가 최종 확인한 대로 Workdesk 전체 디자인 재현 첨부는 참고 자료로만 사용했다. 전체 배치·카드 구조를 다시 개발하지 않았다. 기존 기능·데이터 계약·공개 API·React Wrapper·공통 Theme/Token·운영 main은 보존했다.
|
||||
|
||||
## 작업 정보
|
||||
|
||||
- 작업 폴더: `D:\디자인시스템\HMDesign-OrgChart`
|
||||
- 브랜치: `feature/orgchart-package`
|
||||
- 전/후 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c`
|
||||
- 실제 병렬 수행: Track A/B/C 읽기 전용 분석을 병렬 수행했다. 이후 B는 대비 모듈과 Department Colors, C는 두 Summary Story의 가상 데이터 안내, 메인은 로고 렌더링·CSS·검증·보고서를 담당했다. 같은 파일의 동시 수정은 없었다.
|
||||
- Started At: 2026-10-12 11:55:44 KST
|
||||
- Completed At: 2026-10-12 12:25:20 KST
|
||||
- Actual Duration: 29분 36초
|
||||
- Variance: 최초 예상 하한 45분 대비 15분 24초 단축, 상한 90분 대비 60분 24초 단축. 병렬 분석 및 기존 PASS 재사용으로 재검증 범위를 줄였다.
|
||||
- Estimated Duration: 45–90분. 필요한 범위만 검사하며 시간을 채우기 위한 재검증은 하지 않았다.
|
||||
|
||||
## Track A — Guard: 원인 분석 PASS, 전체 해결 PARTIAL
|
||||
|
||||
공통 Guard를 변경하거나 검사 규칙을 비활성화하지 않았다. 실제 `themeAnalysis`에 인메모리 fixture를 전달한 최소 재현 8/8이 통과했다. 이는 **오탐 재현 PASS이며 실제 Guard PASS가 아니다**.
|
||||
|
||||
최종 PARTIAL/STORYBOOK Guard 결과는 원본 `status=FAIL`, `staticStatus=FAIL`로 보존했다. TOKEN-001 FAIL 8건, TOKEN-002 NOT VERIFIED 6건, SCOPE-001 NOT VERIFIED 3건이다.
|
||||
|
||||
- Shadow CSS 연결 누락 1건: `--hm-orgchart-resolved-bg-surface`는 `design-tokens.css`에 선언되고 공개 CSS로 번들되어 Shadow DOM에 연결된다. Guard graph는 `?url` import와 Shadow link의 연결을 증명하지 못한다.
|
||||
- `setProperty` 정의 미수집 4건: `current-gradient`, `current-card-background`, `current-border`는 `applyOrganizationStyle`이 정의한다. Guard가 이 DOM 선언을 수집하지 않아 참조를 미선언으로 판정한다.
|
||||
- 선택적 입력 오분류 3건: 대비 후보의 `--hm-orgchart-contrast-candidate-color` 및 `--hm-orgchart-contrast-candidate-chip-background`는 로컬 fallback을 가진 외부 override 입력이다. 기본값은 각각 `#10243b`, `#ffffff`이며 실제로 정상 적용된다. 공식 Theme 변수의 누락과 동일하게 처리하는 현재 검사 정책은 이 계약을 표현하지 못한다.
|
||||
- 동적 TOKEN-002 6건: 유한한 조직 preset/legend 문자열 조합을 정적으로 해석하지 못한다.
|
||||
- CSS SCOPE-001 3건: 카드·콘텐츠·통계 CSS는 실제 Core CSS 번들과 Shadow link에 포함되지만 Storybook graph 범위 밖으로 판정된다.
|
||||
|
||||
브라우저에서 Shadow CSS 로딩, 6개 preset의 computed 값, fallback과 HTML/React 동일 렌더링을 확인했다. 이 증거로 raw FAIL을 덮어쓰지 않았다. 검사한 범위에서 실제 Production 미선언 변수 결함은 발견하지 못했다.
|
||||
|
||||
`styleEvidence`: STATIC **PASS**, RUNTIME **NOT VERIFIED**, DESIGN_INTENT **NOT VERIFIED**, FINAL **NOT VERIFIED**. 이 값은 Guard의 증거 연결 상태이며 별도 브라우저 PASS와 구분한다. 이번에는 결합 `styleVerification`으로 승격하지 않았다.
|
||||
|
||||
승인 필요한 최소 공통 Guard 변경안: 증명 가능한 Shadow CSS 연결, `setProperty` 정의 수집, 유한 후보 해석, 명시적 선택적 입력 계약 지원. Guard 분석기·style inventory/증거 연결·관련 회귀 fixture와 문서에 영향이 있으므로 별도 승인 전에 구현하지 않는다. 전역 CSS 강제 연결, dummy 선언, 변수 namespace 변경, fallback 일괄 면제는 적용하지 않았다.
|
||||
|
||||
근거: `test-results/org-chart-quality/guard-reproduction.json`, `guard-storybook.json`. 분석기 SHA 전/후 동일하며 기존 P1 Guard 원본도 유지했다.
|
||||
|
||||
## Track B — Color Contrast: PARTIAL
|
||||
|
||||
기준은 [WCAG 2.2 Contrast Minimum](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)의 일반 텍스트 4.5:1, 큰 텍스트 3:1 및 [Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)의 의미 있는 UI/그래픽 3:1이다. 판정에는 반올림 전 값을 사용했다. 로고를 변색하지 않았다.
|
||||
|
||||
### 측정 방법과 범위
|
||||
|
||||
Core의 제한된 opaque sRGB/세로 Gradient 모델과 실제 Chrome DPR1 PNG를 분리해 기록했다. 모델은 Gradient stop, 전체 구간, DOM Range의 텍스트 line 구간을 구분한다. PNG에서는 텍스트 옆 빈 padding 열의 실제 배경 픽셀과 computed foreground를 사용한다. 안티앨리어싱된 글자 픽셀로 전경색을 추측하지 않았다. 모델과 픽셀 차이가 2 channel 값을 초과하거나 스크롤에 가려지면 NOT VERIFIED다.
|
||||
|
||||
지원하지 않는 alpha·복수 배경·다른 각도·다른 색 공간·합성 효과 등은 임의로 PASS하지 않는다. 이번 PNG 검사의 84개 가려진/일치하지 않은 probe는 NOT VERIFIED로 남겼다. 그중 솔루션영업 두 부서명은 마지막 가지로 스크롤한 별도 캡처에서 추가 검증했다. 숫자는 화면별 반복 측정이며 서로 다른 접근성 요구사항의 개수가 아니다. 전체 WCAG 인증으로 확대하지 않는다.
|
||||
|
||||
### 기존 HEX와 실제 텍스트 대비
|
||||
|
||||
6개 헤더 Gradient와 흰 부서명 `#ffffff`는 Workdesk `v1.2610.9c5e`, 2026-10-12의 기존 computed 근거를 유지했다. 직원 카드 배경은 자체 기본 `#ffffff`이고 테두리 기본은 해당 조직 시작색이다. 카드 면·테두리·geometry는 **DESIGN_ADAPTATION**, 공식 Workdesk 실측으로 승격하지 않았다. 변수 prefix에 `-card-background`, `-department-color`, `-border`, `-gradient`를 각각 사용한다.
|
||||
|
||||
- 사업관리: `#194d82 → #1d395c`, `--hm-orgchart-business-management-*`. 흰색 stop 최소 8.659:1. Full 실제 부서명 9.357:1 **PASS**.
|
||||
- 경영기획: `#2164a8 → #254975`, `--hm-orgchart-business-planning-*`. stop 최소 6.082:1. Full 실제 부서명 약 6.654–6.676:1 **PASS**.
|
||||
- 인재성장: `#0f6ace → #044a94`, `--hm-orgchart-talent-growth-*`. stop 최소 5.286:1. Full 실제 부서명 약 5.879–5.882:1 **PASS**.
|
||||
- 통합시스템 Div: `#8c67b6 → #5d3494`, `--hm-orgchart-integrated-system-*`. stop 최소 4.439:1이지만 Full 실제 텍스트 구간은 약 5.008–5.197:1 **PASS**. stop 하나만으로 실제 텍스트를 FAIL 처리하지 않았다.
|
||||
- 디자인기획: `#09a7a2 → #00847f`, `--hm-orgchart-design-planning-*`. stop 최소 2.974:1. Full의 15px bold 부서명 약 3.188–3.263:1 **REVIEW**. Department Colors의 19.5px bold 제목은 약 3.228:1로 큰 텍스트 기준 **PASS**, 일반 크기 비교는 약 3.263:1 **REVIEW**다. 큰 제목의 PASS를 Full에 대입하지 않았다.
|
||||
- 솔루션영업: `#5a963c → #3f7629`, `--hm-orgchart-solution-sales-*`. stop 최소 3.582:1. 스크롤 후 Full 15px 부서명 약 3.916–3.917:1 **REVIEW**. Department Colors 큰 제목 약 3.922:1 **PASS**, 일반 크기 비교 약 3.922:1 **REVIEW**다.
|
||||
|
||||
위 Full 수치는 1440px 대표 실측이다. Department Colors의 화면 내 MODEL 값은 픽셀 실측과 별도이며 실제 geometry·브라우저 반올림에 따라 차이가 있다. UI에 MODEL임을 명시했다. Default 값만 검사했으며 모든 소비자 override 조합의 대비를 보장하지 않는다.
|
||||
|
||||
기타 확인:
|
||||
|
||||
- 직원 이름 `#10243b / #ffffff`: 15.701:1 **PASS**.
|
||||
- 직위·상태의 보조 텍스트 `#596575 / #ffffff`: 5.927:1 **PASS**. 기본 콘텐츠 면 `#f4f7fb` 기준 약 5.516:1.
|
||||
- 직책 배지 및 통계 헤더/숫자 텍스트: 검사한 solid 조합 **PASS**. 원본 값 변경 없음.
|
||||
- 협업 허브 `#45639f → #809dd7` 위 흰 일반 텍스트: 실제 약 3.321:1 **REVIEW**; stop 최소 약 2.720:1. 허브 색은 Workdesk 실측으로 확인된 브랜드 값이 아니라 자체 디자인 기본값이다.
|
||||
- 관계 표현 연결선 `#bac7d7`: 실제 Storybook 배경 `#f5f0e5`와 약 1.510:1, 흰 면과 1.716:1 **REVIEW**. 모든 카드 프레임·테이블 divider를 무조건 의미 있는 UI 경계로 판정하지는 않았다. 부모-자식 관계 연결선은 정보 전달 그래픽으로 검토했다.
|
||||
- 키보드 focus-visible: 실제 2px solid `#45639f`, Storybook 배경 대비 5.222:1 **PASS**. 전체 키보드/스크린리더 검증으로 확대하지 않았다.
|
||||
|
||||
### 적용한 보완과 승인 대기안
|
||||
|
||||
**실제 Production 브랜드 HEX 변경: 0건.** Department Colors에 `Aa`, 실제 변수/HEX, 카드 배경과 부서명 전경 구분, 일반/큰 텍스트 대비, stop 보수값, MODEL/NOT VERIFIED 및 미적용 후보 표시를 추가했다. resize 시 실제 텍스트 구간을 다시 계산한다.
|
||||
|
||||
중요한 색상·geometry 변경은 승인 없이 적용하지 않았다. 후보는 `UNAPPROVED_NOT_APPLIED`로 표시하며 Full Composition에 반영하지 않았다.
|
||||
|
||||
- 디자인기획 흰 부서명 → `#000000` 후보: 전체 Gradient stop 최소 4.607:1. 텍스트 브랜드 표현 변경에 대한 승인 후 실제 화면 재검증 필요.
|
||||
- 모든 Gradient의 단순 `#10243b` 전경 후보는 어두운 조직에서 실패한다. Department Colors에 실패 수치도 표시해 보편적 해결안으로 제시하지 않았다.
|
||||
- 디자인기획/솔루션영업: 흰 label backing `#ffffff` + `#10243b` 후보는 15.701:1. 기존 Gradient를 유지하지만 새 시각 요소이므로 승인 대기. 솔루션영업은 검정/흰색 어느 하나도 전체 Gradient 전 구간을 일반 텍스트 기준으로 통과하지 않는다(검정 stop 최소 3.835:1).
|
||||
- 큰 bold 텍스트 사용은 별도 대안이며 layout 영향을 확인하고 승인한 뒤 실제 geometry에서 다시 검사해야 한다.
|
||||
- 허브: 흰 backing + 짙은 텍스트 또는 배경/크기 보완 승인 필요. 후보 수치와 actual 허브 적용은 구분했다.
|
||||
- 의미 있는 연결선 `#bac7d7 → #7b899d` 후보: 실제 Storybook 배경 3.128:1, 흰 면 3.555:1, `#f4f7fb` 3.308:1. `--hm-orgchart-stroke-default` 보완 범위와 시각 의도 승인 후 적용할 수 있다. 적용하지 않았다.
|
||||
|
||||
근거: `browser.json`, `contrast-pixels.json`, `contrast-last-branch.json`, `boundaries.json` 및 대응 PNG.
|
||||
|
||||
## Track C — Storybook: 변경 표시 범위 PASS
|
||||
|
||||
사용자용 메뉴 **Logo / Department Colors / Position Summary / Affiliation Summary / Full Composition** 5개 유지. 기존 내부 Story 8개 및 테스트 로직 유지.
|
||||
|
||||
- Logo: Figma 정식 Footer에서 확보한 7개 PNG(한맥·삼안·장헌·장헌산업·피티씨·한라·바론)와 ID 매핑 유지. 473×473 원본 SHA와 RGBA 투명성 유지, 수정·재제작·가상 대체 없음. 누락 Asset **0건**. 원본 이미지 비율은 contain으로 보존했다.
|
||||
- 로고 최소 수정: 이미지의 흰 CSS 배경을 transparent로 변경하고, 로고 정상일 때 fallback 글자가 투명 영역 뒤에 보이지 않도록 숨겼다. 이미지 실패 시 fallback을 다시 표시하며 관계사명 접근성 이름을 유지했다. 기존 실제 broken-image PASS를 재사용하고 이번에는 변경된 handler 표시만 검사했다.
|
||||
- 한라처럼 가로 wordmark인 원본은 직원 16px mark에서 실제 ink 높이가 약 4px, Summary 28px에서는 약 7px라 작은 크기의 판독성 한계가 남는다. 원본 비율을 임의로 crop/recolor하지 않았다. 크기/여백 변경은 후속 디자인 검토 대상이다.
|
||||
- Department Colors: 6개 조직명, 실제 HEX/CSS Variable, Gradient/Border, 원본 출처와 자체 디자인 구분, 실제 computed/model 대비·미승인 후보 표시 정상. 390px 줄바꿈 정상.
|
||||
- Position Summary / Affiliation Summary: 두 Story에 가상 직원 자동 집계·운영 인원 아님 안내를 추가했다. Core selector/표/정책은 변경하지 않았다. 가상 raw total 41, 관계사 0명 세 항목 표시를 유지한다. 실제 인사 집계 정책 승인으로 간주하지 않는다.
|
||||
- Full Composition: 리더·허브·6개 그룹과 부서/팀·직원 카드·직위 통계·관계사 통계·파견/휴직을 유지했다. Header·GNB·LNB·Dashboard Layout 없음. 390/768에서는 통계 아래 배치, 1280/1440에서는 오른쪽 통계 배치. 조직도 및 표의 내부 스크롤 유지, 변경으로 인한 새 겹침/페이지 overflow 없음.
|
||||
- Position Summary 최초 390px PNG의 안내 첫 paint가 불완전해 그 화면만 다시 확인했다. DOM·최종 PNG에서 두 줄 전체 문장 정상. 원본 캡처를 보존하고 `position-note-recheck.json`과 `storybook-position-summary-390-final.png`를 최종 근거로 사용한다.
|
||||
- HTML/React: 4개 폭의 computed 카드·로고 스타일 일치. 이미 촬영한 PNG를 비교한 결과 390/768/1280/1440 모두 픽셀 동일. 별도 React UI를 추가하지 않았다.
|
||||
|
||||
독립 API는 변경하지 않았다. `OrgChartTree`, `PositionSummary`, `AffiliationSummary`, `StatusSummary`의 기존 factory/React Wrapper/P1 독립 사용 PASS를 재사용한다. 두 Summary의 변경 Story 렌더링은 이번 브라우저 검사로 확인했다. 사용법은 기존 [공개 컴포넌트·스타일 API](standalone-components-p1.md)를 유지한다.
|
||||
|
||||
확인된 Workdesk 차이는 유지했다: IS/디자인 하위 팀의 세로 배치, 추가 root 카드·ID/raw metadata·collapse control, 더 큰 전체 높이, 18행 상세 통계, 1440px Story host 제한과 마지막 가지 내부 스크롤, 파견/휴직의 after-tree 배치. 이는 이번 변경으로 생긴 회귀가 아니며 사용자가 예고한 별도 전체 디자인 작업에서 다룬다. 픽셀 완전 일치 PASS가 아니다.
|
||||
|
||||
근거: `test-results/org-chart-quality/visual-review.json`. Workdesk 최신 화면은 조직명/색/배치 참고로만 사용했고 실제 직원 개인정보·운영 인원수를 fixture에 복제하지 않았다. Figma Dev Mode 미확인 속성을 실측이라고 기재하지 않았다.
|
||||
|
||||
## 검증 및 재사용
|
||||
|
||||
이번에 실행:
|
||||
|
||||
- Core Typecheck: `npm.cmd run typecheck:org-chart` **PASS**.
|
||||
- 변경 Story/preview source Typecheck: `npm.cmd exec -- tsc -p tsconfig.app.json --noEmit` **PASS**.
|
||||
- Core Package Build: `npm.cmd run build:org-chart` **PASS**(ESM/IIFE/types/CSS).
|
||||
- HTML demo prepare 및 React demo Vite Build **PASS**. React Wrapper 구현 변경 없음.
|
||||
- Storybook production Build **PASS**. 기존 큰 chunk 경고가 있으며 이번 작업에서 배포 구조를 리팩터링하지 않았다.
|
||||
- 신규 대비 unit **10/10 PASS**: 수학/비반올림 경계/일반·큰 텍스트/Gradient 구간/unsupported NOT VERIFIED. 기존 unit output을 지우지 않는 별도 폴더로 실행.
|
||||
- 변경 UI browser **62/62 PASS**, console/runtime error 0. 5개 Story 390/1440 및 Full 768/1280, Theme 미연결 HTML/React 4개 폭 확인.
|
||||
- Position 390 안내만 후속 **PASS**, 가려진 솔루션영업 제목 2곳 후속 대비 **REVIEW**. 정상 데이터/생명주기 검사를 반복하지 않았다.
|
||||
- Guard 재현 **8/8 PASS**; 최종 관련 Guard 원본 **FAIL**(앞서 원인/범위 기록).
|
||||
- 실제 텍스트 대비: 검증된 부분 PASS/REVIEW 구분, 가려지거나 모델 불일치한 부분 NOT VERIFIED 유지.
|
||||
- `git diff --check` **PASS**. 운영 `D:\디자인시스템\HMDesign`은 읽기 전용 확인: `main`, 동일 HEAD, Working Tree clean. Commit/Push/Merge/새 브랜치/공통 코드 수정 없음.
|
||||
|
||||
재사용한 근거: 기존 Core 22/22, HTML 69/69, React 79/79, P1 selector 7/7, P1 browser의 성공 196건 및 console/menu 후속 4/4. 원본 기록은 [P1 구현 보고](implementation-report-p1.md)와 `test-results/org-chart-p1/`에 유지한다. Data Source/validation/집계/JSON 갱신/lifecycle/API/Wrapper/원본 Story 로직이 변경되지 않아 전체 테스트를 다시 실행하지 않았다. 현재 검사는 변경된 스타일·표시·신규 대비 모델·관련 Guard 문제에 한정했다.
|
||||
|
||||
## 변경 파일 — 이번 품질 작업만
|
||||
|
||||
시작 706파일 SHA snapshot과 비교해 기존 파일 삭제 0건, 수정 7건, 신규 소스/검증 6건과 이 보고서 1건이다. 기존 `package.json`, `package-lock.json` 미커밋 변경, 원본 자산·7개 설계 문서·P1 보고/Story·Spike·공통 코드의 시작값은 보존했다.
|
||||
|
||||
수정:
|
||||
|
||||
1. `packages/org-chart/src/core/affiliation-mark.ts` — fallback span과 정상/오류 표시 연결.
|
||||
2. `packages/org-chart/src/core/department-colors.ts` — 대비 정보와 미적용 비교 후보.
|
||||
3. `packages/org-chart/src/styles/employee-card.css` — 로고 투명 배경 및 fallback 숨김.
|
||||
4. `packages/org-chart/src/styles/org-chart-content.css` — Department Colors 비교 표시만.
|
||||
5. `packages/org-chart/src/styles/summary-sections.css` — standalone 주석 정정만.
|
||||
6. `src/org-chart/core-component-preview.tsx` — Department Colors resize 계산/cleanup.
|
||||
7. `src/org-chart/org-chart-p1.stories.tsx` — 두 독립 Summary의 가상 데이터 안내.
|
||||
|
||||
신규:
|
||||
|
||||
8. `packages/org-chart/src/core/contrast.ts` — 내부 대비 모델(공개 API 추가 없음).
|
||||
9. `packages/org-chart/src/core/contrast.test.ts` — 신규 모델 10개 검증.
|
||||
10. `examples/org-chart/verify-guard-p1.mjs` — Guard 최소 오탐 재현.
|
||||
11. `examples/org-chart/verify-quality-p1.mjs` — 변경 UI 62개 검사/PNG/값 수집.
|
||||
12. `examples/org-chart/audit-quality-pixels.py` — PNG 읽기 전용 대비 감사.
|
||||
13. `examples/org-chart/verify-quality-boundaries.mjs` — 실제 연결선/포커스 CSS 확인.
|
||||
14. `docs/org-chart/quality-review-p1.md` — 이번 결과 기록.
|
||||
|
||||
`test-results/org-chart-quality/`에는 신규 증거, 실행 로그, PNG 및 baseline을 별도 보존했다. Build 산출물은 기존 생성 경로에서 갱신했다.
|
||||
|
||||
## 최종 판정 / 승인 / 후속
|
||||
|
||||
**PARTIAL**: 로고 표시·Department Colors 설명·가상 안내·변경 렌더링 보완은 PASS다. 실제 대비 부족과 공통 Guard 개선 승인이 남아 전체 P1을 PASS로 처리하지 않는다. 원본 Guard FAIL은 분석상 오탐으로 구분했지만 승인·수정 없이 PASS로 치환하지 않았다.
|
||||
|
||||
승인 대기: 디자인기획/솔루션영업/허브의 텍스트·backing·크기 선택, 의미 있는 연결선 대비 보완, 공통 Guard 제한적 추적 개선. 모두 미적용이다. Workdesk 전체 배치 수정은 사용자가 지정한 별도 Codex 작업으로 남긴다.
|
||||
|
||||
NOT VERIFIED: 전체 Heap/GC 회수, 전체 스크린리더, 미확인 Figma Dev Mode 속성, Workdesk 픽셀 완전 일치, 실제 HR API, 미승인 집계 정책, 무관한 Guard 항목, 지원하지 않거나 가려진 contrast probe, 모든 소비자 override의 접근성.
|
||||
|
||||
후속 P2: 전체 디자인 작업 결과의 확인, 승인된 대비 대안만 적용/부분 재검사, 작은 wordmark 크기 검토, 승인된 집계 정책·실제 API·전체 접근성·메모리 검증. Commit·Push·Merge는 수행하지 않았다.
|
||||
|
||||
## 완료 기록
|
||||
|
||||
Started At: **2026-10-12 11:55:44 KST**. Completed At: **2026-10-12 12:25:20 KST**(최종 검사 종료 시각). Actual Duration: **29분 36초**. Estimated Duration: **45–90분**. Variance: **하한 대비 −15분 24초 / 상한 대비 −60분 24초**. 보고서 정리와 결과 표시 시간은 별도다.
|
||||
|
||||
최종 보존 근거: `test-results/org-chart-quality/preservation.json` — baseline 706개 중 수정 7개, 유지 699개, 삭제 0개, 신규 7개(보고서 포함), HEAD/main 동일, diff check 0. Preview: `http://127.0.0.1:6027/?path=/story/orgchart--full-composition`.
|
||||
@@ -0,0 +1,83 @@
|
||||
# HMDesign OrgChart v0.1 React Wrapper · Storybook 통합 검증
|
||||
|
||||
RESULT: **PASS** — 이번 요청의 React 통합 MVP 완료 조건 13개를 충족했습니다. 아래 NOT VERIFIED는 별도 한계로 보존하며 모든 기능·디자인·배포 환경의 검증 완료를 뜻하지 않습니다.
|
||||
|
||||
## 구현 결과
|
||||
|
||||
1. 작업 경로 `D:\디자인시스템\HMDesign-OrgChart`, 브랜치 `feature/orgchart-package`. 사용자 첨부 요청의 별도 작업/Git 지시를 적용했습니다. 운영 `D:\디자인시스템\HMDesign` 및 `main`은 수정하지 않았습니다.
|
||||
2. 작업 전/후 HEAD, Local main, 공식 원격 main/Feature 모두 `d18e3c33801e165e17b6d971e2f6198eed483c3c`. 공식 origin은 `https://gitea.hmac.kr/b25027/HMDesign.git`입니다. 시작/완료 시 원격을 읽기 전용으로 확인했습니다.
|
||||
3. 신규 `packages/org-chart-react`, `@hm-design/org-chart-react@0.1.0`. Core `@hm-design/org-chart@0.1.0` dependency, React `^19.0.0` peer. React DOM은 Wrapper가 사용하지 않아 별도 dependency/peer를 추가하지 않았으며 소비 앱이 제공합니다. 설치 package/lock과 React/DOM `19.3.0`, Storybook `10.6.1`, 엔진 `6.0.0` 일치를 확인했습니다.
|
||||
4. Runtime Export는 `OrgChart`. Type Export는 `OrgChartProps` 및 Core 공개 타입의 type-only re-export입니다. Props는 공개 `OrgChartOptions`에서 container/onStateChange를 제외하여 재사용합니다. 필수 `dataSource`, `stylesheetUrls`; 선택 `aggregationPolicy`, `className`, `style`, `onStateChange`, `onLifecycleError`. 제목·직원·Zoom 등 임의 Props는 추가하지 않았습니다.
|
||||
5. 생명주기는 Core 생성 → 구독 → mount, Source identity 교체 → reload, cleanup → 구독 해제·destroy입니다. 최초 추가 Reload가 없고 Callback 변경만으로 재생성하지 않습니다. Core가 변경 setter를 제공하지 않는 Policy identity/CSS URL 값 변경은 정리 후 재생성합니다. 같은 URL 문자열의 새 객체는 재생성하지 않습니다. Source/Policy 내부 mutation은 변경 알림으로 취급하지 않습니다.
|
||||
6. committed Callback, 소유권, Source 갱신을 순서대로 layout effect에 연결했습니다. commit과 passive cleanup 사이의 오래된 요청 Callback 가능 구간을 닫습니다. generation/operation 검사는 이전 인스턴스 Promise의 늦은 rejection을 차단하며 Core revision/AbortSignal이 오래된 데이터 응답을 차단합니다. Wrapper 안에 DOM 엔진·Validation·Summary·노드 UI·엔진 정리 로직을 복제하지 않았습니다.
|
||||
7. ESM과 선언을 자체 최소 build.mjs로 생성하며 React, JSX runtime, Core를 external로 유지합니다. 산출물의 client boundary를 보존했습니다. 기존 공통 build 스크립트는 수정하지 않았습니다. 최종 Wrapper ESM 약 3.09 kB/gzip 0.92 kB입니다.
|
||||
8. 기존 HM Theme/폰트를 호스트에서 제공하고 공개 engine.css/style.css의 실제 URL을 Shadow DOM에 주입합니다. Storybook의 기존 preview CSS/decorator를 그대로 사용하며 별도 소비자는 공개 Theme entry를 사용합니다. Production의 해시 Asset URL과 폰트 로드를 실제 확인했습니다. 새 Theme·브랜드 팔레트나 reset을 만들지 않았습니다.
|
||||
9. `src/org-chart/org-chart.stories.tsx`, 분류 `OrgChart/Content`. 기존 glob에 포함되므로 `.storybook/main.ts`/preview 변경이 없습니다. 기존 Component/Pattern Story를 바꾸지 않았습니다. 모든 Story는 공개 Wrapper와 Strict Mode를 사용합니다.
|
||||
10. 필수 Story 8개: Basic Organization, Organization Hierarchy, Employee Cards, Organization Summary, JSON Data Update, Loading / Empty / Error, Responsive Container, Multiple Instances. 기존 HTML v1/v2 JSON을 직접 import하며 추가 시나리오는 파생 가상 데이터 및 같은 Source Interface만 사용합니다. 주변 조작은 공개 HM Button을 실제 렌더링합니다.
|
||||
11. Navigation·Header·Dashboard·CSV·추가 업무 메뉴·API는 없습니다. FilterBar/DataTable은 검색·필터·정렬·선택 요구가 없어 사용하지 않습니다. 노드/상태/집계는 Framework-independent Core의 승인된 책임이며 React HM 위젯으로 복제하지 않습니다.
|
||||
12. 실제 empty 화면을 구현한 것으로 표시하지 않습니다. 직원 0명은 Ready, 조직 0개는 Error입니다. Zoom/Pan은 비활성 상태로 유지합니다. hubText/showDispatched/showOnLeave 등 기존 reserved 영역, 복잡한 relation overlay도 추가 구현하지 않았습니다.
|
||||
|
||||
## 최종 통합 검증
|
||||
|
||||
- **PASS** Wrapper Typecheck: `npm run typecheck:org-chart-react`. 최종 생명주기 수정 후 다시 확인했습니다.
|
||||
- **PASS** 루트 앱·Storybook TypeScript graph: `tsc -p tsconfig.app.json --noEmit`. 별도 React 소비자도 strict Bundler 타입 검사를 통과했습니다.
|
||||
- **PASS** Wrapper Package Build: ESM/선언/client boundary/external 확인. Storybook prebuild의 기존 workspace 빌드도 Core/UI/Center Graphic을 포함해 성공했습니다.
|
||||
- **PASS** 실제 npm tarball Public Export 및 선언: 추출된 Core/Wrapper에서 DOM 없는 ESM import, strict NodeNext consumer, 필수 Source와 미지원 Zoom/container 타입 경계 확인. React/compiler는 현재 검증 환경에서 제공합니다. 새 PC의 fresh dependency install 결과로 해석하지 않습니다.
|
||||
- **PASS** Storybook Production Build: 최종 Wrapper/Story/CSS 상태의 `storybook build` 성공. 큰 기존 chunk 및 plugin timing 경고가 있으나 build 실패는 없습니다.
|
||||
- **PASS** Storybook 실제 브라우저: Production의 8개 Story와 개발 6025의 실제 Wrapper 노드 실행. JSON v2 갱신, Unmount/Mount, Source 오류·복구, 직원/조직 0개, 취소를 무시하는 늦은 응답, A 갱신/제거 시 B identity·데이터·Callback 유지 확인.
|
||||
- **PASS** 독립 React Consumer: `examples/org-chart-react`의 공개 package import·Vite Production Build·실제 Chrome 실행, 초기 mount/Source 교체/최신 Callback/Unmount. Storybook 성공을 소비자 성공으로 대신하지 않았습니다.
|
||||
- **PASS** 개발 React Strict Mode: setup → cleanup → setup에서 두 번의 Source load와 최종 Ready 1회, 활성 Core host 1개. Production은 replay 없이 초기 load/Ready 각 1회임을 별도로 확인했습니다. 두 Source load는 Strict Mode의 의도된 setup 반복이며 Wrapper의 초기 이중 Reload가 아닙니다.
|
||||
- **PASS** 생명주기: Callback/className 변경 시 같은 Core·추가 load 없음; Source 교체 시 같은 Core reload; Policy/CSS URL 변경 시 새 Core; 같은 CSS URL 값은 유지. 실패한 구성의 lifecycle callback, 정상 오류→Ready 복구, 늦은 응답·Unmount 후 Callback 차단, 5회 반복 mount/unmount의 DOM/관찰자 기준 유지, 최종 Core DOM/관찰자 0 확인.
|
||||
- **PASS** HTML/React 공통 데이터: v1 조직 3개·직원 4명·직접 Root/A/B 1/2/1, v2 조직 3개·직원 5명·직접 1/1/3. DOM의 organization ID/parentId·employee ID·노드 텍스트·Heading·Summary를 비교하여 일치했습니다.
|
||||
- **PASS** Theme CSS: HTML/React의 전경·surface·radius·폰트 computed 일치, 기존 Theme 변수 상속, 두 CSS link의 실제 stylesheet 로드, scoped Theme 변경 시 다른 인스턴스 유지, Engine selector의 light DOM 오염 없음.
|
||||
- **PASS** 반응형: 실제 390/768/1280/1440px에서 소비자 및 Basic/Employee/Responsive Story의 document overflow·노드 겹침·텍스트 폭·null 사진 fallback 확인. scrollWidth/clientWidth 및 scrollHeight/clientHeight 측정을 저장했습니다. 390px은 원래 노드 크기와 내부 가로 스크롤을 유지합니다. 실제 scrollLeft 이동도 확인했습니다. 소비자 Grid item min-width와 Story Grid track을 수정하여 문서 overflow를 제거했습니다.
|
||||
- **PASS** 키보드: Enter/Space 접기·펼치기 및 데이터 교체로 기존 focus가 제거될 때 안정된 Core viewport로 복귀. 접기 애니메이션이 끝나기 전 재입력은 기존 Core가 거부하므로 검사도 실제 transition 완료를 기다립니다.
|
||||
- **PASS** 기존 Vanilla: 기존 HTTP HTML의 v1→v2 비교 및 `file://` classic의 Ready 4명→Update 5명 smoke. 변경 없는 기존 22개 데이터 테스트/69개 전체 브라우저 검사는 반복하지 않았습니다.
|
||||
- **PASS** Chrome `155.0.8059.39`: 통합 검사 **70/70**, 추가 경계 검사 **9/9**, console/pageerror **0**. 대표 390px 긴 텍스트·Responsive 및 1440px 소비자 캡처도 육안 확인했습니다.
|
||||
- **PASS** 보존: 시작 파일 **614/614 SHA-256 일치**, 기존 Core/HTML/7개 설계 문서/이전 구현 보고/UI/Center Graphic/Theme/Story/공유 build 설정 보존. `git diff --check` 통과. 운영 main Working Tree는 시작·완료 시 깨끗합니다. Staged diff는 비어 있으며 Stage/Commit/Push/Merge가 없습니다.
|
||||
|
||||
## Guard와 미검증
|
||||
|
||||
- Storybook과 독립 소비자에 범위별 PARTIAL Guard를 실행했습니다. 최종 원본 status/staticStatus는 **NOT VERIFIED**, FAIL 0입니다. Storybook의 동적 width, 소비자의 동적 className 및 실제 브라우저 증명 요구를 그대로 유지합니다.
|
||||
- 원본 CSS styleEvidence는 **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED**입니다. 별도 브라우저 computed·컨테이너 검사 PASS는 기록한 요소/상태/폭의 증거이며 Guard 원본이나 디자인 의도 승인을 덮어쓰지 않습니다. Guard 결합 판정은 수행하지 않았습니다.
|
||||
- 최초 소비자 Guard의 target 상대경로 입력이 맞지 않아 checkedFiles=[] 진단이 발생했습니다. 원본 `guard-consumer-initial.json`을 보존하고 상대경로/entry를 교정한 실제 graph 검사를 별도로 저장했습니다. Story CSS 수정 후 CSS-only PARTIAL은 NOT APPLICABLE이어서 최종 Story graph 검사를 별도로 저장했습니다. 빈 검사 결과를 PASS로 계산하지 않았습니다.
|
||||
- **NOT VERIFIED** 완전한 Heap/GC 회수, 전체 스크린리더·SSR/hydration, 모든 브라우저/모바일 touch, 매우 큰 데이터 성능·깊이, Workdesk 원본과의 시각적 일치, Theme 미연결 스타일, 실제 API/auth, fresh npm registry 설치 및 임의 배포 base/CSP/origin입니다.
|
||||
- Scope 한계: 공개 Props의 arbitrary className/style 및 모든 외부 데이터·Theme 조합은 전수 검사하지 않았습니다. readOnly/disabled 입력 필드는 없는 콘텐츠 패키지이며 전체 업무 화면 접근성 검증으로 해석하지 않습니다.
|
||||
- 기술적 리스크: 기존 Core의 엔진 6.0.0 내부 init observer hook 의존은 유지됩니다. 엔진 upgrade 시 정리 계약 재검토가 필요합니다. Policy identity 변경은 재생성되므로 immutable/memoized Props를 권장합니다. Core 갱신 시 expansion/scroll state 보존은 추가하지 않았습니다.
|
||||
- 도구 이슈: 로컬 npm symlink EPERM 및 sandbox Chrome loopback 차단은 허용된 실행 권한으로 재시도해 해결했습니다. 최초 sandbox Storybook의 사용자 설정/registry 쓰기 경고도 접근 가능한 개발 서버 재실행 후 사라졌습니다. 운영 6024 서버를 중단하지 않았습니다.
|
||||
|
||||
## 파일과 증거
|
||||
|
||||
이번 변경(기존 작업 위에 추가):
|
||||
|
||||
- 수정 `package.json`: 기존 OrgChart scripts 유지, Wrapper build/typecheck·소비자 build/dev/browser scripts, Core/Wrapper root dependency 연결.
|
||||
- 수정 `package-lock.json`: root Core/Wrapper dependency, Wrapper workspace link·manifest. 해당 추가를 제거하고 원래 CRLF로 재직렬화한 SHA가 시작 lockfile SHA와 일치하므로 기존 dependency graph/버전은 보존했습니다. npm 재직렬화로 line ending은 LF입니다.
|
||||
- 신규 `packages/org-chart-react/{package.json,build.mjs,tsconfig.json,tsconfig.build.json,README.md,src/OrgChart.tsx,src/types.ts,src/index.ts}`.
|
||||
- 신규 `src/org-chart/{org-chart.stories.tsx,org-chart-stories.css}`.
|
||||
- 신규 `examples/org-chart-react/{index.html,index.css,app.tsx,vite.config.ts,README.md,verify.mjs,verify-package.mjs,verify-boundaries.mjs}`.
|
||||
- 신규 이 보고서. 시작 시 기존 package/lock의 Core 관련 diff와 untracked docs/examples/Core는 이번 신규 변경과 구별하여 보존했습니다.
|
||||
|
||||
검증 생성물(Git ignored):
|
||||
|
||||
- `test-results/org-chart-react/report.json`: 70개 검사, HTML/React snapshots, four-width 측정과 PNG.
|
||||
- `test-results/org-chart-react/boundaries.json`: 추가 9개 경계 검사.
|
||||
- `test-results/org-chart-react/tarball-consumer/report.json`: public archive/declaration 검사.
|
||||
- `test-results/org-chart-react/preservation.json`, `baseline.json`, `baseline-lock-reconstructed.json`: 시작/완료 보존 근거.
|
||||
- `test-results/org-chart-react/guard-storybook-final.json`, `guard-consumer.json`: 최종 원본 Guard. 초기/부분 진단도 별도 보존.
|
||||
- `test-results/org-chart-react/storybook-build.log`: 최종 Production Build 로그.
|
||||
|
||||
확인용 개발 URL:
|
||||
|
||||
- Storybook `http://127.0.0.1:6025/?path=/story/orgchart-content--basic-organization`.
|
||||
- 독립 React 소비자 `http://127.0.0.1:6026/`.
|
||||
- 기존 운영 Storybook `6024`, 원래 PID `19932` 유지.
|
||||
|
||||
## 시간 기록 (Asia/Seoul)
|
||||
|
||||
- Started At: 2026-10-12 10:24:24 KST
|
||||
- Completed At: 2026-10-12 10:42:39 KST
|
||||
- Actual Duration: 18분 15초
|
||||
- Estimated Duration: 최초 2~3시간
|
||||
- Variance: 예상 하한 2시간 대비 101분 45초 단축; 예상 상한 3시간 대비 161분 45초 단축
|
||||
|
||||
예상 시간은 참고값입니다. 기존 Core/설치 환경과 병렬 구현을 재사용해 실제 시간이 줄었으며 미검증을 PASS로 대체하지 않았습니다. Commit·Push·Merge는 사용자 요청대로 진행하지 않았습니다.
|
||||
@@ -0,0 +1,60 @@
|
||||
# OrgChart P1 독립 디자인과 개별 컴포넌트
|
||||
|
||||
2026-10-12 최종 요청을 적용한다. 기존 공통 Theme 필수 연결 규칙은 OrgChart에 적용하지 않는다. 과거 v0.1 검증 기록은 당시 구현 기록으로 보존한다.
|
||||
|
||||
## 공개 API와 스타일
|
||||
|
||||
패키지 root 공개 API에 `createOrgChartTree`, `createPositionSummary`, `createAffiliationSummary`, `createStatusSummary`를 추가한다. 기존 `createOrgChart`, Source, schema, summary 및 React Wrapper API는 유지한다. 모든 factory가 기존 `mount/update/reload/destroy/subscribe/getState` handle을 반환한다.
|
||||
|
||||
```js
|
||||
import { createPositionSummary, createAffiliationSummary, createOrgChartTree,
|
||||
JsonOrgChartDataSource } from '@hm-design/org-chart';
|
||||
import coreUrl from '@hm-design/org-chart/style.css?url';
|
||||
import engineUrl from '@hm-design/org-chart/engine.css?url';
|
||||
|
||||
const source = new JsonOrgChartDataSource(dataset);
|
||||
const position = createPositionSummary({ container: positionElement,
|
||||
dataSource: source, stylesheetUrl: coreUrl });
|
||||
const affiliation = createAffiliationSummary({ container: affiliationElement,
|
||||
dataSource: source, stylesheetUrl: coreUrl });
|
||||
const tree = createOrgChartTree({ container: treeElement, dataSource: source,
|
||||
stylesheetUrls: { core: coreUrl, engine: engineUrl } });
|
||||
await Promise.all([position.mount(), affiliation.mount(), tree.mount()]);
|
||||
// 상위 페이지가 DOM container를 자유롭게 배치하고 표시·숨김을 관리한다.
|
||||
await position.update(nextDataset);
|
||||
position.destroy(); affiliation.destroy(); tree.destroy();
|
||||
```
|
||||
|
||||
`?url`은 Vite 사용 예시다. 일반 HTML은 배포한 CSS 실제 URL을 전달한다. `/browser` classic bundle도 같은 factory를 `window.HMOrgChart`로 제공한다. ESM import/handle 생성은 DOM 없이 가능하며 mount에만 연결된 HTMLElement가 필요하다. Summary는 engine을 로드하지 않고 engine.css도 필요 없다. 각 모듈은 Header·Routing·전체 페이지에 의존하지 않는다.
|
||||
|
||||
React에서는 기존 전체 `OrgChart` Wrapper를 그대로 사용한다. 개별 모듈은 위 factory를 ref/effect에서 mount하고 cleanup에서 destroy하면 된다. JSX 카드·테이블·데이터 계산을 복제하지 않는다. Source는 공유할 수 있으며 각 handle은 동일한 validate/normalize/calculateSummary 흐름을 사용한다. 상위 페이지가 여러 handle에 nextDataset을 전달한다. 자동 전역 데이터 저장소는 추가하지 않는다.
|
||||
|
||||
## 독립 디자인 입력
|
||||
|
||||
OrgChart는 Shadow DOM 안에서 `engine.css → style.css`를 적용한다. `style.css`는 모든 내부 CSS를 포함하며 공통 HM Theme가 없어도 기본 디자인을 가진다. 공통 Theme·Token·Component는 변경하지 않는다.
|
||||
|
||||
```css
|
||||
.other-page-summary {
|
||||
--hm-orgchart-font-family: Arial, sans-serif;
|
||||
--hm-orgchart-spacing-lg: 20px;
|
||||
--hm-orgchart-radius-container: 6px;
|
||||
--hm-orgchart-bg-surface: #ffffff;
|
||||
}
|
||||
.custom-chart {
|
||||
--hm-orgchart-design-planning-card-background: #f4ffff;
|
||||
--hm-orgchart-design-planning-card-color: #10243b;
|
||||
--hm-orgchart-design-planning-department-color: #ffffff;
|
||||
--hm-orgchart-design-planning-border: #00847f;
|
||||
--hm-orgchart-design-planning-gradient: linear-gradient(#00847f, #005b57);
|
||||
}
|
||||
```
|
||||
|
||||
위 예시는 외부 사용자 override 예시이며 공식 Workdesk 값 확정이 아니다. public 변수를 container에 설정한다. `resolved-*`, `current-*`는 내부 구현 변수다. 기본 변수 전체 목록은 `packages/org-chart/src/styles/design-tokens.css`, 조직별 목록·실측 근거는 `core/style-presets.ts`에서 관리한다. 조직별 ID에 연결하고 하위 셀·팀은 상위 variant를 상속한다. JSON 직원 객체에 컬러를 추가하지 않는다. 카드 배경·직원 텍스트·부서명 텍스트·테두리·Gradient를 각각 바꿀 수 있다.
|
||||
|
||||
## Composition·데이터·디자인 근거
|
||||
|
||||
`core/org-chart-content.ts`에서 독립 Tree와 Summary 모듈을 선택해 배치한다. 기본 placement는 직위=side, 관계사=footer, 파견·휴직=belowTree이며 내부 `ContentComposition.placements`로 바꿀 수 있다. 외부 호출자는 공개 개별 API의 container를 직접 배치한다. 기존 presentation flags로 전체 조합의 표시를 관리하며 계약 필드 추가 없이 처리한다.
|
||||
|
||||
Workdesk `v1.2610.9c5e` 운영 화면에서 확인한 조직 명칭과 6개 조직 헤더 Gradient·흰색 부서명 텍스트를 사용한다. 일반 배경·카드 테두리·Typography·간격·Radius는 독립 구현 기본값(DESIGN_ADAPTATION); 원본 카드 세부값은 NOT VERIFIED다. Figma 접근 가능한 정식 회사 로고 7종은 Core에서 affiliation ID로 매핑하고 카드·현황·Logo에서 재사용한다. 출처·원본 SHA256은 `packages/org-chart/src/assets/affiliations/README.md`에 기록한다. 미매핑/파일 오류는 회사 명칭 텍스트 대체를 사용하고 임의 로고를 생성하지 않는다.
|
||||
|
||||
P1 fixture는 실제 조직명과 관계사명만 사용한다. 직원 41명과 직위·직책·소속·근무 상태는 가상이며 프로필은 null/이니셜 placeholder다. 관계사 3곳은 0명을 유지한다. Raw Count는 가상 JSON 고유 employee ID로 자동 계산하고 실제 HR 인원수나 승인된 집계 정책으로 표시하지 않는다.
|
||||
@@ -0,0 +1,114 @@
|
||||
# OrgChart UI Composition
|
||||
|
||||
## P3.2 후속 — 대표자 뱃지 미사용 (2026-10-12)
|
||||
|
||||
대표자 카드는 모든 실행 경로와 독립 견본에서 직책·역할·상태 뱃지 및 빈 attributes/뱃지 행을 생성하지 않는다. 표시 직위 `대표이사`, 이름 `홍 길 동`, 승인 카드 146×74 및 기존 Typography·사진·로고를 유지하며 직위↔이름 간격은 0px이다. 원본 fixture의 대표자 직위는 `경영진`이므로 `대표이사`는 카드 표시에만 적용한다. 직원 JSON·정보 팝업 원문·공개 API와 다른 1Depth/2Depth 카드의 뱃지 정책은 그대로 유지한다. 아래 P3.2 및 이전 보고서·검증은 당시 기록으로 보존한다.
|
||||
|
||||
## P3.2 현재 계약 (2026-10-12)
|
||||
|
||||
[P3.2 보고서](implementation-report-p3.2.md)의 카드 Typography·상태 영역·로고 보정이 아래 P3.1 수치보다 우선한다. 과거 검증 기록은 그대로 보존한다.
|
||||
|
||||
- 대표자 Typography는 유지한다. 대형 이름 24px/600/30px, 직위 18px/400/22px; 소형 이름 18px/600/24px, 직위 15px/400/18px이다. 기존 컬러·letter-spacing·Noto Sans KR 및 `홍 길 동`을 유지한다. 직위·뱃지 공백은 표시 계층에서만 제거한다.
|
||||
- readOnly 파견·휴직은 SVG 없이 일반 소형 카드의 공통 콘텐츠 레이아웃과 Typography를 재사용한다. 카드 108×52, 사진 24×44, 사진↔텍스트 4px, 직위↔이름 0px이다. 기존 회색 표면·문자색·로고 grayscale을 유지하며 별도 상태 행은 없다. P3.1의 실제 114×54/사진 28×28에서 이번 요청값으로 보정한다.
|
||||
- 상태 타이틀은 일반 2Depth 기본 20px/700/19.7px를 공유하며 회색 Gradient를 유지한다. 경영기획/인재성장 18px/600/16.9px 예외는 보존한다. 타이틀↔첫 카드 Gap 8px를 유지한다.
|
||||
- 카드 로고의 실제 contain 표시 영역은 소형 12×12→18×18, 대형·대표자·파견·휴직 14×14→21×21이다. 원본 PNG·승인 SVG 및 하단 관계사 현황 로고는 수정하지 않는다. 직원 카드 selector에만 적용하며 비율·투명 배경을 유지한다.
|
||||
|
||||
## P3.1 현재 계약 (2026-10-12)
|
||||
|
||||
잔여 디자인 보완의 최신 계약은 [P3.1 보고서](implementation-report-p3.1.md)가 우선한다. 아래 P3/P2 및 초기 설계의 검증 기록은 당시 범위 그대로 보존한다.
|
||||
|
||||
- OrgChart 전용 기본 폰트는 Noto Sans KR이다. 기존 `public/fonts/NotoSansKR-variable.ttf`와 호스트 font-face를 재사용하며 HM Theme는 수정하지 않는다. 한국어 Core shell은 `lang="ko"`로 HTML/React/Storybook의 글리프 조형을 맞춘다.
|
||||
- 이름은 `홍 길 동`, 직위는 표시에서만 공백을 제거한다. Full Composition은 `jobTitle`이 `경영진`인 뱃지만 생성하지 않으며 기타 직책 문자열·원본 데이터·팝업·통계는 유지한다. 원본 `position="경영진"` 텍스트는 유지한다.
|
||||
- 1Depth는 20px/700/36px·헤더 높이 36px이다. 2Depth 경영기획/인재성장은 18px/600/16.9px, 나머지는 기존 20px/700/19.7px다. 114×30.4 표면·Gradient·Radius·Shadow 및 모든 타이틀↔첫 카드 Gap 8px를 보존한다.
|
||||
- 허브는 원본 21px/750/24px·아이콘 20px·Gap 8px·좌우 Padding 22/24px·높이 42px를 유지한다. 최대 폭 상속 제약을 해제하고 X 935 중심축을 유지한다.
|
||||
- 사진 없음/로드 실패는 공통 `person-placeholder.svg`를 사용한다. 실제 사진이 있으면 우선한다. 승인 사진 슬롯 36×62/32×56/24×44, P3 상태 카드 슬롯 28×28 및 비원형 처리를 유지한다.
|
||||
- 우측 표는 전체 330px 안에서 조직명/경영진 열을 112/46px로 재배분해 한 줄로 표시한다. 나머지 열은 잔여 폭을 나눈다. 숨김/말줄임/폰트 축소 없이 원본 14px, 기존 계산 41명·위치·셀 정렬·행 높이를 보존한다.
|
||||
- 대표자 146×74, 1Depth 146×68, 하위 108×52 및 P3 상태 카드 114×54를 유지한다. 확장형 Auto Layout/layoutConfig·메뉴·Navigation·공개 API 추가 없음.
|
||||
|
||||
## P3 현재 계약 (2026-10-12)
|
||||
|
||||
아래 초기 설계와 P2 기록은 보존한다. 충돌하는 표시 계약은 [P3 보고서](implementation-report-p3.md)가 우선한다. 범위는 기존 독립 HTML·React 공통 Core와 해당 Storybook 페이지이며 Navigation·Header를 추가하지 않는다. 기존 semantic table/native dialog는 공통 Vanilla Core adapter를 유지하기 위한 선택이다. FilterBar·DataTable의 검색/정렬/페이지 기능은 요구되지 않아 추가하지 않는다.
|
||||
|
||||
- Full Composition 기본 배경 `#F5F8FB`, 원본 장식·허브 곡선·협업 화살표 유지. 조회 전용 엔진의 하위 연결선 생성 경로는 모두 숨긴다.
|
||||
- 대표자 146×74, 1Depth 146×68, 하위 108×52 유지. 모든 조직 타이틀↔첫 카드 간격 8px, 직위/뱃지↔이름 행 0px. 이름만 `홍 길 동`, 직위·뱃지는 원문.
|
||||
- 하위 헤더는 Workdesk 소스의 114×30.4 표면·상위 조직 Gradient·Radius 7.1·Shadow를 적용한다. HR은 18px/600/16.9, 다른 헤더는 20px/700/19.7이다. 허브는 원본 SVG 아이콘 20px·Gap 8px·텍스트 21px/750/24를 사용한다.
|
||||
- 사진 없음은 원형 기본 아바타 대신 빈 사각형 예약 영역이다. 원본에 기본 이미지가 없으며 승인 카드의 기존 폭을 유지하는 디자인 조정이다. 실제 직원 사진은 포함하지 않는다.
|
||||
- 우측은 직위별 표(주요 조직 하위 포함, 루트 직속, 직위별 열, 소계, 계). 가상 직원 41명·원본 조직 관계·독립 PositionSummary API를 보존한다. 상태 그룹의 카드 하단 상태 문구만 제거한다. 관계사 바는 제목 없는 한 줄이다.
|
||||
- 축소는 단일 비례 배율 `min(1, 가용 폭 / Stage 폭, 실제 컨테이너 가용 높이 / Stage 높이)`다. 연결 후 실제 Shadow/호스트 조상과 viewport를 관찰한다. 임의 Header 높이나 viewport 절반 offset 제한은 없다.
|
||||
- `organizationLinks`가 지정된 1Depth 제목만 링크다. `onEmployeeClick`이 없으면 Stage 바깥 native modal에서 기존 직원 정보를 조회한다. 닫기·ESC·키보드 Focus를 지원한다.
|
||||
- 사용자 메뉴는 `Logo · Personnel Summary`, `Department Colors`, `Full Composition`이다. 이전 단독 Story ID는 내부 태그로 보존한다.
|
||||
|
||||
실제 브라우저 측정과 미검증은 P3 보고서에 기록한다. 과거 PASS/FAIL을 이번 상태로 덮어쓰지 않는다.
|
||||
|
||||
상태: 콘텐츠 설계, 실행·시각 검증 미수행. [README](README.md)의 확정 범위와 최신 Core 독립성을 따른다. Workdesk 캡처 파일은 이번 첨부에 없어 아래 구성은 사용자 텍스트의 8영역을 기준으로 한다. 실제 이미지와의 배치·색상·카드·곡선 비교는 **NOT VERIFIED**다.
|
||||
|
||||
위 상태와 초기 설계 절은 과거 기록이다. 현재 조회 전용 구현은 3a절과 [P2 구현·검증 보고서](implementation-report-p2.md)를 따른다. P2에서는 읽기 전용 Workdesk 로컬 소스와 자산을 확인하고 실제 HTML·React·Storybook을 검증했다. 운영 Workdesk와의 전체 픽셀 일치 여부는 별도 미검증이다.
|
||||
|
||||
## 1. 콘텐츠 구성과 데이터 연결
|
||||
|
||||
1. **제목·설명**: PresentationConfig의 title/description과 Metadata 기준일. Header·PageHeader·브랜드 Navigation을 생성하지 않는다.
|
||||
2. **최상위 리더 카드**: 승인된 root 조직의 leaderId를 Employee에 연결한다. 공석·여러 root·겸직 leader 표현은 TBD다. 같은 직원을 중복 집계하지 않는다.
|
||||
3. **중앙 허브**: hubText의 콘텐츠. 조직 entity인지 설명 허브인지, 리더와의 연결 방식은 TBD다. 조직장 ID나 직원 ID를 허브에 임의 부여하지 않는다.
|
||||
4. **조직별 그룹**: Organization parent/type/order로 부문·실·Div를 구성한다. 조직 사이 기본 계층 연결선은 engine가 담당한다.
|
||||
5. **셀·팀·구성원**: 같은 조직 계약에서 cell/team을 표현하고 직원은 organizationId로 조회한다. 이름·직위·직책·이미지/fallback·상태를 조합한다. 직원 전체를 조직 트리의 nested 원본으로 저장하지 않는다.
|
||||
6. **우측 인원 현황**: 조직 직접/하위 포함·직위별 등 승인된 집계. 우측은 넓은 컨테이너에서의 배치 후보이며 작은 화면에서는 문서 흐름으로 이동하는 안을 검토한다.
|
||||
7. **파견·휴직 영역**: 같은 직원 원본의 상태별 그룹. 본 조직 카드와 동시 노출/이동 표시 정책은 TBD이며 표시와 집계를 분리한다.
|
||||
8. **관계사별 인원·총원**: Affiliation과 승인 정책을 사용하는 집계. 조직별 하위 포함 수를 단순 합산하지 않는다. 집계 정책 미승인 시 미확정 상태를 설명한다.
|
||||
|
||||
추가 조직 관계는 OrganizationRelation에서 별도 표시한다. 관계 의미·방향·범례·곡선·anchor는 승인 전 확정하지 않는다. 관계를 parent hierarchy로 오인하지 않도록 텍스트 설명을 함께 제공하는 안을 검토한다. 고정 좌표로 인원 수나 조직 배치를 하드코딩하지 않는다.
|
||||
|
||||
## 2. HM 재사용과 Core DOM 경계
|
||||
|
||||
최신 사용자 요구에 따라 Core는 React Component를 import하지 않는다. OrgChart 전용 표준 HTML/CSS가 **공통 Component를 대체하는 신규 HM primitive**가 되지 않도록 범위를 콘텐츠에 제한한다. 기존 Component·Pattern·Theme·대표 Story는 보존한다.
|
||||
|
||||
재사용의 두 경로를 구별한다.
|
||||
|
||||
- Core: HM CSS Custom Properties와 기존 Typography·Spacing·Radius 기준을 참조한다. 노드에는 전용 class를 쓰고 HM `data-slot` 또는 일반 Card/Avatar selector를 흉내내지 않는다. 이 예외와 기존 React 지침의 충돌은 README에 기록했다.
|
||||
- React 소비 앱(Core 콘텐츠 밖): 적합한 주변 콘텐츠에서 공개 `@hm-design/ui` API를 실제 렌더링하는 방향을 검토한다. Wrapper는 Core summary/loading/error/node를 다른 HM UI로 대체하지 않는다. Core 노드마다 React root를 만드는 설계도 채택하지 않는다. 공통 Core의 콘텐츠·디자인·인터랙션을 유지한다.
|
||||
|
||||
확인한 공개 자료:
|
||||
|
||||
- [Card 문서](../components/card.md), [Card All](../../src/components/card.stories.tsx), [구현](../../packages/ui/src/components/ui/card.tsx): surface/content와 Header/Title/Description/Content 조합. Core 외부의 React 콘텐츠 묶음에서 재사용 후보다.
|
||||
- [Avatar 문서](../components/avatar.md), [Avatar All](../../src/components/avatar.stories.tsx), [구현](../../packages/ui/src/components/ui/avatar.tsx): 이미지/fallback, sm/default/lg. Core는 `<img>`/안전한 텍스트 fallback을 사용하고 React 호스트에서는 공개 Avatar 후보를 검토한다.
|
||||
- [Table 문서](../components/table.md), [Table All](../../src/components/table.stories.tsx): 정적 표이며 정렬/선택/페이지는 DataTable 책임이다. 요청된 Summary는 동일 Core의 semantic table로 설계한다. React 소비 앱의 별도 주변 표에서 공개 Table을 검토할 수 있지만 Wrapper가 Summary를 중복 구현하지 않는다. 기존 Table 최소 폭 520px은 Core의 확정 최소 폭이 아니며 사용한 주변 표는 390px 내부 스크롤을 확인한다.
|
||||
- [공개 인덱스](../guidelines/component-pattern-reference.md)의 Badge/Alert/Skeleton/ScrollArea/Button/Label은 해당 기능 확정 시 문서·대표 Story·Props를 더 확인한다. 현재 import나 재사용 PASS로 보고하지 않는다.
|
||||
|
||||
Pattern 선택 근거: FilterBar는 검색·조회 조건용이다. 현재 검색/필터 요구가 없어 추가하지 않는다. DataTable의 정렬/선택/페이지도 요구가 없어 사용하지 않는 방향이다. DashboardLayout/GNB/LNB/PageHeader는 페이지 Shell/Navigation 책임이므로 제외한다. LoadingScreen은 전체 화면용이라 콘텐츠 loading에 바로 사용하지 않는다. 상태 콘텐츠는 Core가 관리하고 React 주변 조합은 기존 Alert/Skeleton을 검토한다. 이는 개발 시 선택 근거이며 구현 재사용 검증은 **NOT VERIFIED**다.
|
||||
|
||||
## 3. 컨테이너·반응형·Overflow
|
||||
|
||||
Core는 부모의 실제 콘텐츠 크기를 사용한다. viewport에서 Header 높이를 빼거나 GNB/LNB/Dashboard 모드를 읽지 않는다. Dashboard 소비자가 960px 내부 콘텐츠를 주는 경우에도 이를 Core 전역 최소 폭으로 일반화하지 않는다.
|
||||
|
||||
390/768/1280/1440px 각각에서 콘텐츠 컨테이너를 직접 제한하여 검사한다. 숫자는 검증 기준이며 확정된 breakpoint·카드 폭이 아니다.
|
||||
|
||||
- 390px: 가로 조직도 내부 스크롤·읽을 수 있는 텍스트 배율, 요약/상태 영역 흐름 배치, touch와 focus 접근을 검토한다. 전체 화면을 축소해 텍스트를 읽을 수 없게 하는 기본값은 확정하지 않는다.
|
||||
- 768px: 조직 분기 wrap과 가로 스크롤, summary의 옆/아래 배치, 긴 조직명·직위·ID를 검사한다.
|
||||
- 1280/1440px: 전체 계층·리더·허브·우측 summary·추가 관계·직원 카드의 겹침과 여백을 검사한다.
|
||||
- 조직 수·깊이·직원 수가 증가하면 내부 scroll container가 확장되고 페이지 전체 가로 Overflow는 발생하지 않아야 한다. CSS transform zoom은 layout 크기와 다를 수 있으므로 보이는 영역과 scrollWidth/clientWidth를 함께 측정한다.
|
||||
- `ResizeObserver`·이미지 로드·폰트 로드·노드 확장·줌·스크롤마다 Overlay 좌표가 동일 좌표계에서 갱신돼야 한다. transform 중복 적용과 stale anchor를 검사한다.
|
||||
- 모바일 방식은 내부 가로 scroll, branch 축소, 세로 대체 표현 중 PoC로 결정한다. 대체 표현은 승인 전 추가 개발 요구로 만들지 않는다. 최소 콘텐츠 폭·zoom 한계·초기 배율·wheel 우선순위·높이 제약·스크롤 책임은 TBD다.
|
||||
|
||||
문서와 내부 컨테이너의 scrollWidth/clientWidth 및 scrollHeight/clientHeight를 기록한다. absolute 알림/overlay의 위치 기준은 해당 인스턴스의 relative 컨테이너여야 한다. 긴 label 줄바꿈과 ID 한 줄 식별 가능성, 접힌 영역에 숨은 focus, 두 인스턴스의 독립 scroll/zoom을 검사한다.
|
||||
|
||||
### 3a. 조회 전용 Full Composition (`displayMode: 'readOnly'`, 2026-10-12)
|
||||
|
||||
- Storybook `OrgChart / Full Composition`, HTML·React `p1.html?mode=readOnly`가 사용한다. 기본값 `interactive`와 내부 검증 Story·독립 컴포넌트는 변경하지 않는다.
|
||||
- 생성하지 않는 요소: 하위 조직 접기/펼치기 button, 조직 종류·ID·원본 직원 수·조직장 안내 문구, 제목 설명·추가 관계 안내, 상태 그룹 정책 문구. 조직 그룹 외곽 카드(배경·Border·Radius·Padding)는 CSS로 제거하고 조직/셀·팀 헤더·카드·연결선은 유지한다.
|
||||
- 배치: Workdesk 실측 1920 × 914.5 캔버스, 대표자·허브 축 X 935, 조직 헤더 X 32/268/505/858/1336/1689(`core/workdesk-layout.ts`, organization ID 기준 presentation preset). 통합시스템 Div·디자인기획 하위는 가로, 나머지는 세로. preset이 없는 조직이 있으면 중앙 흐름 배치로 대체한다.
|
||||
- 요약: 우측 상단 `조직별 인원 현황`(루트 직속 + 주요 조직 하위 포함 인원 + 계), 좌하단 파견·휴직(제목 108px pill, 카드 유지), 하단 관계사 바(제목 없음, 로고·회사명·인원·총계). 모두 같은 Summary/selector에서 계산한다.
|
||||
- 축소: `scale = min(1, 가용 폭 / 1920, (viewport 높이 − 문서 offset) / 914.5)`(offset은 viewport 절반까지만 반영). transform scale 1회, 내부 가로 스크롤 없음.
|
||||
- P2 카드: 실제 `parentId` 조상 관계로 Depth를 계산한다. 대표자 146×74, 1Depth 조직장만 146×68, 2Depth 이하 조직장은 해당 부서의 108×52 소형 SVG를 사용한다. semantic leader와 직책 뱃지는 유지하며 interactive·독립 카드 선택은 보존한다. 전달 자산이 없는 조직의 기존 fallback 정책은 유지한다.
|
||||
- 최신 확정 표시: 조회 전용 직원 이름은 대표자·파견·휴직을 포함해 모두 `홍 길 동`. 직위·직책·이름의 연속 한글 사이에는 표시 계층에서 공백을 넣고 접근성 이름은 원문을 사용한다. 원본 JSON 이름·ID·조직·인원 계산은 변경하지 않는다. 기존 font-size/weight/line-height/color와 내부 0px 행 간격을 유지한다.
|
||||
- P2 간격: 1Depth 헤더 높이 36px, Y 약 254.19px, 대형 카드 사이 실제 8px. 2Depth 헤더와 첫 카드 사이 실제 10px. 하단 상태/관계사 슬롯은 측정 캔버스 내에서 고정 배치하여 전체 높이 914.5px와 겹침 없는 배치를 유지한다.
|
||||
- P2 장식: `core/workdesk-artwork.ts`가 원본 compact 허브 곡선 3개·중앙선·사업관리 추가 곡선, PNG 협업 화살표 5개와 배경/허브 이미지 5개를 같은 Stage에 렌더한다. 원본 9종 자산은 byte 그대로 복사하고 ESM/IIFE에 포함한다. 기존 상단 pseudo 연결선은 이 preset에서 제거한다. 하위 팀의 실사용 원본 SVG는 없어 기존 engine 관계 경로를 유지한다.
|
||||
- P2 검증: HTML HTTP·classic file://·React production·Storybook에서 1920/1440/1280/768/390px 484건 PASS. Guard 원본의 NOT VERIFIED와 런타임 PASS는 별도 증거로 보존한다. 상세 측정·남은 차이·기존 FAIL은 P2 보고서에 기록한다.
|
||||
|
||||
## 4. 접근성·상태·개인정보
|
||||
|
||||
Core DOM은 제목 계층·조직/직원 그룹·표의 caption/header를 semantic하게 구성한다. engine 기본 DOM이 완전한 ARIA tree라고 가정하지 않는다. tree 역할 채택 시 키보드 계약 전체를 검증하고, 그렇지 않으면 계층 설명을 제공할 구조를 검토한다.
|
||||
|
||||
확장·축소/zoom 조작이 구현될 경우 키보드로 접근 가능한 표준 button과 접근성 이름·focus indicator·disabled 상태를 제공한다. readOnly 데이터 조직도에 수정·drag 메뉴를 추가하지 않는다. 상호작용 후 focus 복귀, 제거된 노드 focus 이동, 오류/로딩 안내의 screen reader 노출과 과도한 알림을 검사한다. 입력 컨트롤이 새로 승인되면 React 호스트는 공개 Label, Core는 framework 독립성 때문에 native label/control 연결을 명시한다.
|
||||
|
||||
이미지 접근 실패는 가상 fallback을 보여주며 불필요한 이름 중복 읽기를 피한다. 사진의 origin·권한·실패 정책은 API 연동 시 결정한다. 직원 ID·이름·사진 원본을 로그/오류 메시지에 무분별하게 남기지 않는다. reference의 실제 인물을 fixture로 재사용하지 않는다.
|
||||
|
||||
Loading/Empty/Error/Ready, 직원 없는 조직, 공석 leader, 상태 그룹 비어 있음, 정책 없는 집계, 갱신 중 상태를 별도 검증한다. 갱신 중 기존 화면 유지와 부분 오류 허용 정책은 TBD다. 사용자 검토 전에 이러한 설계 후보를 기능 PASS로 표시하지 않는다.
|
||||
@@ -0,0 +1,82 @@
|
||||
# OrgChart React public-package consumer
|
||||
|
||||
## P3 follow-up
|
||||
|
||||
`p1.html?mode=readOnly` uses the updated common Core with the position matrix,
|
||||
group links and employee information dialog. No navigation URLs are preset.
|
||||
The development-only `window.OrgChartP1React.setOptions({ organizationLinks,
|
||||
onEmployeeClick })` helper applies public React props to the current fixture;
|
||||
it is not a new Core handle method. `setData` remains available.
|
||||
|
||||
After building Core/Wrapper and both examples, run the focused
|
||||
`node examples/org-chart/verify-p3.mjs`. Evidence goes to
|
||||
`test-results/org-chart-p3`; prior P1/P2 scripts and results remain unchanged.
|
||||
|
||||
This independent Vite consumer imports the built public entries of
|
||||
`@hm-design/org-chart-react`, `@hm-design/org-chart` and `@hm-design/ui/theme.css`.
|
||||
It imports the original Vanilla v1/v2 JSON files directly. It is a verification
|
||||
example, with fictitious data and a `window.OrgChartReactDemo` inspection seam;
|
||||
it has no server/login integration or production business controls.
|
||||
|
||||
From the repository root, build packages before the consumer:
|
||||
|
||||
```sh
|
||||
npm run build:packages
|
||||
npm run build:org-chart-react-example
|
||||
npm run dev:org-chart-react-example
|
||||
```
|
||||
|
||||
Development URL: `http://127.0.0.1:6026/` (strict port). Strict Mode effect replay
|
||||
is checked in development. The production output has its own HTTP browser smoke
|
||||
and update checks. This ESM output is for HTTP deployment; it is not a direct-open
|
||||
`file://` artifact. The existing Vanilla `examples/org-chart/dist/classic.html`
|
||||
remains the direct-open example.
|
||||
|
||||
Core CSS URL imports become deployment assets and are passed into each Shadow DOM.
|
||||
The public HM Theme import emits its existing font assets. Grid/flex consumers
|
||||
must allow their chart-containing items to shrink (`min-width: 0`); horizontal
|
||||
overflow belongs to the Core viewport rather than the document.
|
||||
|
||||
Browser tooling is supplied by the verification environment, not added to the
|
||||
package dependencies. Configure `ORGCHART_PLAYWRIGHT_MODULE` and optionally
|
||||
`ORGCHART_CHROME`, using absolute paths. Start the separate Storybook server on
|
||||
6025 and consumer on 6026, then:
|
||||
|
||||
```sh
|
||||
# Set these environment variables using your shell's syntax:
|
||||
# ORGCHART_CONSUMER_URL=http://127.0.0.1:6026
|
||||
# ORGCHART_STORYBOOK_URL=http://127.0.0.1:6025
|
||||
npm run test:org-chart-react-browser
|
||||
node examples/org-chart-react/verify-boundaries.mjs
|
||||
```
|
||||
|
||||
`verify.mjs` inspects real Core DOM, compares Vanilla/React snapshots, exercises
|
||||
React and Storybook lifecycles and records screenshots/metrics at four widths.
|
||||
`verify-boundaries.mjs` checks production updates, source identity semantics,
|
||||
actual horizontal scrolling, global CSS isolation and direct-open Vanilla smoke.
|
||||
Both write evidence under ignored `test-results/org-chart-react/`.
|
||||
|
||||
To check the actual package archive and declarations without another build:
|
||||
|
||||
```sh
|
||||
npm pack -w @hm-design/org-chart --ignore-scripts --pack-destination test-results
|
||||
npm pack -w @hm-design/org-chart-react --ignore-scripts --pack-destination test-results
|
||||
node examples/org-chart-react/verify-package.mjs
|
||||
```
|
||||
|
||||
The tarball test uses extracted Core/Wrapper packages with the environment's
|
||||
React/compiler; it does not establish a fresh remote-registry installation.
|
||||
|
||||
## P2 read-only Full Composition
|
||||
|
||||
Open `p1.html?mode=readOnly` to use the same read-only Core composition as the
|
||||
Vanilla example and Storybook `orgchart--full-composition`. Plain `p1.html`
|
||||
retains the existing interactive mode. The query changes presentation only.
|
||||
All displayed names are `홍 길 동`; the employee fixture and aggregation remain
|
||||
unchanged. Department leaders use card assets by actual ancestry depth.
|
||||
|
||||
After building the Core, preparing the HTML example and building this consumer,
|
||||
run `node examples/org-chart/verify-p2.mjs` from the repository root. Set
|
||||
`ORGCHART_STORYBOOK_URL` to the current Storybook server when checking dev.
|
||||
The focused verifier records screenshots and measurements in
|
||||
`test-results/org-chart-p2`; it preserves existing v0/P1 test expectations.
|
||||
@@ -0,0 +1,91 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { OrgChart, type OrgChartProps } from '@hm-design/org-chart-react';
|
||||
import { JsonOrgChartDataSource, type OrgChartDataSource, type OrgChartState } 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 v1 from '../org-chart/data-v1.json';
|
||||
import v2 from '../org-chart/data-v2.json';
|
||||
import './index.css';
|
||||
|
||||
// An independent production-built consumer of public package entries. This
|
||||
// inspection seam exercises React Props only; it never reaches into the engine.
|
||||
const events: { instance: string; callback: number; state: OrgChartState }[] = [];
|
||||
const lifecycleErrors: string[] = [];
|
||||
const requests: Record<string, { loads: number; aborts: number }> = {};
|
||||
function track(name: string, source: OrgChartDataSource): OrgChartDataSource {
|
||||
requests[name] = { loads: 0, aborts: 0 };
|
||||
return {
|
||||
load(options) {
|
||||
requests[name].loads++;
|
||||
options?.signal?.addEventListener('abort', () => { requests[name].aborts++; }, { once: true });
|
||||
return source.load(options);
|
||||
},
|
||||
};
|
||||
}
|
||||
const noEmployees = structuredClone(v1);
|
||||
noEmployees.employees = [];
|
||||
for (const organization of noEmployees.organizations) organization.leaderId = null;
|
||||
const noOrganizations = structuredClone(v1);
|
||||
noOrganizations.organizations = [];
|
||||
const sources = {
|
||||
v1: track('v1', new JsonOrgChartDataSource(v1)),
|
||||
v2: track('v2', new JsonOrgChartDataSource(v2)),
|
||||
zeroEmployees: track('zeroEmployees', new JsonOrgChartDataSource(noEmployees)),
|
||||
zeroOrganizations: track('zeroOrganizations', new JsonOrgChartDataSource(noOrganizations)),
|
||||
error: track('error', { async load() { throw new Error('Fictitious source failure'); } }),
|
||||
};
|
||||
let releaseSlow: (() => void) | undefined;
|
||||
const slowSource = track('slow', {
|
||||
load() { return new Promise(resolve => { releaseSlow = () => resolve(structuredClone(v1)); }); },
|
||||
});
|
||||
type Configuration = {
|
||||
sourceA: OrgChartDataSource;
|
||||
sourceB: OrgChartDataSource;
|
||||
mountedA: boolean;
|
||||
mountedB: boolean;
|
||||
callback: number;
|
||||
aggregationPolicy: OrgChartProps['aggregationPolicy'];
|
||||
stylesheetUrls: OrgChartProps['stylesheetUrls'];
|
||||
className: string;
|
||||
};
|
||||
let configuration: Configuration = {
|
||||
sourceA: sources.v1, sourceB: sources.v2, mountedA: true, mountedB: false,
|
||||
callback: 1, aggregationPolicy: undefined,
|
||||
stylesheetUrls: { engine: engineUrl, core: coreUrl }, className: 'consumer-chart',
|
||||
};
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
function render(patch: Partial<Configuration> = {}) {
|
||||
configuration = { ...configuration, ...patch };
|
||||
const callback = configuration.callback;
|
||||
const chart = (instance: 'A' | 'B', source: OrgChartDataSource) => (
|
||||
<section key={instance} aria-label={`OrgChart ${instance}`} data-instance={instance}>
|
||||
<OrgChart
|
||||
dataSource={source}
|
||||
aggregationPolicy={configuration.aggregationPolicy}
|
||||
stylesheetUrls={configuration.stylesheetUrls}
|
||||
className={configuration.className}
|
||||
onStateChange={state => { events.push({ instance, callback, state }); }}
|
||||
onLifecycleError={error => { lifecycleErrors.push(error instanceof Error ? error.message : String(error)); }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<p className="consumer-note">가상 JSON 데이터 · React Strict Mode · 공개 OrgChart 패키지 사용</p>
|
||||
<div className="consumer-charts">
|
||||
{configuration.mountedA && chart('A', configuration.sourceA)}
|
||||
{configuration.mountedB && chart('B', configuration.sourceB)}
|
||||
</div>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
const demo = {
|
||||
render, sources, slowSource, events, lifecycleErrors, requests,
|
||||
releaseSlow: () => releaseSlow?.(),
|
||||
get configuration() { return configuration; },
|
||||
destroy: () => root.unmount(),
|
||||
};
|
||||
declare global { interface Window { OrgChartReactDemo: typeof demo } }
|
||||
window.OrgChartReactDemo = demo;
|
||||
render();
|
||||
@@ -0,0 +1,9 @@
|
||||
@import "@hm-design/ui/theme.css";
|
||||
|
||||
/* Consumer content geometry; the chart owns its internal scroll container. */
|
||||
body { margin: 0; background: var(--hm-sem-bg-content); color: var(--hm-sem-fg-primary); font-family: var(--hm-font-family-base); }
|
||||
main { padding: 16px; }
|
||||
.consumer-charts { display: grid; gap: 24px; min-width: 0; }
|
||||
.consumer-charts > section { min-width: 0; }
|
||||
.consumer-chart { min-width: 0; max-width: 100%; }
|
||||
.consumer-note { margin: 0 0 16px; }
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="data:," />
|
||||
<title>HM OrgChart React Consumer</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="root"></main>
|
||||
<script type="module" src="./app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
/* Bundler resolves the same font used by HTML and the HM Storybook host. */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans KR';
|
||||
src: url('../../public/fonts/NotoSansKR-variable.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
body { margin: 0; background: #ffffff; }
|
||||
main { padding: 16px; }
|
||||
.consumer-chart { min-width: 0; }
|
||||
html[data-orgchart-mode="readOnly"] body { background: #F5F8FB; }
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>HM OrgChart P1 · React Full Composition</title><link rel="icon" href="data:,"></head>
|
||||
<body><main><div id="root"></div></main><script type="module" src="./p1.tsx"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { OrgChart, type OrgChartProps } from '@hm-design/org-chart-react';
|
||||
import { JsonOrgChartDataSource, type OrgChartState } 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 fixture from '../org-chart/data-p1.json';
|
||||
import './p1.css';
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const events: OrgChartState[] = [];
|
||||
const errors: string[] = [];
|
||||
const displayMode = new URLSearchParams(location.search).get('mode') === 'readOnly' ? 'readOnly' : 'interactive';
|
||||
document.documentElement.dataset.orgchartMode = displayMode;
|
||||
type QueryOptions = Pick<OrgChartProps, 'organizationLinks' | 'onEmployeeClick'>;
|
||||
let queryOptions: QueryOptions = {};
|
||||
let currentData: unknown = fixture;
|
||||
function setData(data: unknown) {
|
||||
currentData = data;
|
||||
root.render(<StrictMode><OrgChart
|
||||
dataSource={new JsonOrgChartDataSource(data)} stylesheetUrls={{ engine: engineUrl, core: coreUrl }} className="consumer-chart"
|
||||
displayMode={displayMode}
|
||||
{...queryOptions}
|
||||
onStateChange={state => events.push(state)} onLifecycleError={error => errors.push(String(error))}
|
||||
/></StrictMode>);
|
||||
}
|
||||
// Optional public-prop seam for consumer integration checks; no URLs are preset.
|
||||
function setOptions(options: QueryOptions) { queryOptions = options; setData(currentData); }
|
||||
const demo = { data: fixture, events, errors, setData, setOptions, destroy: () => root.unmount() };
|
||||
declare global { interface Window { OrgChartP1React: typeof demo } }
|
||||
window.OrgChartP1React = demo;
|
||||
setData(fixture);
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const { chromium } = await import(process.env.ORGCHART_PLAYWRIGHT_MODULE ? pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href : 'playwright');
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const file = path.resolve(root, 'examples/org-chart-react/dist', '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
|
||||
const base = path.join(root, 'examples/org-chart-react/dist');
|
||||
const relative = path.relative(base, file);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; }
|
||||
response.setHeader('Content-Type', { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf' }[path.extname(file)] || 'application/octet-stream');
|
||||
response.end(await readFile(file));
|
||||
} catch { response.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
|
||||
const results = [], errors = [];
|
||||
const check = (condition, name, detail) => { results.push({ name, status: condition ? 'PASS' : 'FAIL', detail }); assert.ok(condition, name); };
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 900 } });
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('console', message => { if(message.type() === 'error') errors.push(message.text()); });
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}`);
|
||||
await page.waitForFunction(() => window.OrgChartReactDemo?.events.at(-1)?.state.status === 'ready');
|
||||
check(await page.evaluate(() => window.OrgChartReactDemo.requests.v1.loads === 1 && window.OrgChartReactDemo.events.filter(event => event.state.status === 'ready').length === 1), 'production mount has one initial load and one ready callback');
|
||||
const layout = await page.evaluate(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport');
|
||||
viewport.scrollLeft = viewport.scrollWidth - viewport.clientWidth;
|
||||
return { width: document.documentElement.clientWidth, documentScrollWidth: document.documentElement.scrollWidth, internalScrollLeft: viewport.scrollLeft };
|
||||
});
|
||||
check(layout.documentScrollWidth <= 390 && layout.internalScrollLeft > 0, 'production 390px page containment and actual internal horizontal scrolling', layout);
|
||||
const pollution = await page.evaluate(() => {
|
||||
const container = document.createElement('div'), node = document.createElement('div');
|
||||
node.textContent = 'Light DOM CSS isolation probe'; container.append(node); document.body.append(container);
|
||||
const read = () => { const style = getComputedStyle(node); return { width: style.width, margin: style.margin, background: style.backgroundColor, textAlign: style.textAlign, border: style.border, font: style.fontFamily }; };
|
||||
const before = read(); container.className = 'orgchart'; node.className = 'node'; const after = read(); container.remove();
|
||||
return { before, after };
|
||||
});
|
||||
check(JSON.stringify(pollution.before) === JSON.stringify(pollution.after), 'Engine selectors do not pollute light DOM', pollution);
|
||||
const hostId = await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance');
|
||||
await page.evaluate(async () => {
|
||||
const demo = window.OrgChartReactDemo;
|
||||
const next = await demo.sources.v2.load();
|
||||
demo.sources.v1.load = async () => structuredClone(next);
|
||||
demo.render({ callback: 9, className: 'consumer-chart boundary' });
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector('.consumer-chart.boundary'));
|
||||
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId && await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.summary.raw.total === 4), 'same source internal mutation and unrelated render do not imply reload');
|
||||
await page.evaluate(() => { const demo = window.OrgChartReactDemo; demo.render({ sourceA: { load: options => demo.sources.v1.load(options) } }); });
|
||||
await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1)?.state.dataVersion === 'mock-2');
|
||||
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId && await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.summary.raw.total === 5 && window.OrgChartReactDemo.events.at(-1).callback === 9), 'production source identity update uses same Core with new 5-person summary and latest callback');
|
||||
await page.evaluate(() => window.OrgChartReactDemo.render({ mountedA: false }));
|
||||
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host'));
|
||||
check(await page.locator('.hm-orgchart-host').count() === 0, 'production unmount removes Core');
|
||||
await page.goto(pathToFileURL(path.join(root, 'examples/org-chart/dist/classic.html')).href);
|
||||
await page.waitForFunction(() => window.orgChartReady);
|
||||
check(await page.evaluate(() => window.OrgChartDemo.chart.getState().summary.raw.total === 4), 'existing direct-open Vanilla classic remains ready with common v1 total 4');
|
||||
await page.evaluate(() => window.OrgChartDemo.update());
|
||||
check(await page.evaluate(() => window.OrgChartDemo.chart.getState().summary.raw.total === 5), 'existing direct-open Vanilla classic v2 update remains total 5');
|
||||
check(errors.length === 0, 'boundary browser checks have zero console/runtime errors', errors);
|
||||
} catch(error) {
|
||||
results.push({ name: 'verification completion', status: 'FAIL', detail: error.stack }); process.exitCode = 1;
|
||||
} finally {
|
||||
await writeFile(path.join(root, 'test-results/org-chart-react/boundaries.json'), JSON.stringify({ browser: await browser.version(), results, errors }, null, 2));
|
||||
await browser.close(); await new Promise(resolve => server.close(resolve));
|
||||
console.log(JSON.stringify({ pass: results.filter(result => result.status === 'PASS').length, fail: results.filter(result => result.status === 'FAIL').length, errors }));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const consumer = path.join(root, 'test-results/org-chart-react/tarball-consumer');
|
||||
await mkdir(consumer, { recursive: true });
|
||||
for (const name of ['org-chart', 'org-chart-react']) {
|
||||
const destination = path.join(consumer, 'node_modules/@hm-design', name);
|
||||
await mkdir(destination, { recursive: true });
|
||||
execFileSync('tar.exe', ['-xzf', path.join(root, `test-results/hm-design-${name}-0.1.0.tgz`), '-C', destination, '--strip-components=1']);
|
||||
}
|
||||
await writeFile(path.join(consumer, 'package.json'), JSON.stringify({ name: 'org-chart-react-tarball-consumer', private: true, type: 'module' }));
|
||||
const require = createRequire(path.join(consumer, 'package.json'));
|
||||
const module = await import(pathToFileURL(require.resolve('@hm-design/org-chart-react')).href);
|
||||
assert.deepEqual(Object.keys(module), ['OrgChart']);
|
||||
assert.equal(typeof module.OrgChart, 'function');
|
||||
const manifest = JSON.parse(await readFile(path.join(consumer, 'node_modules/@hm-design/org-chart-react/package.json'), 'utf8'));
|
||||
assert.deepEqual(manifest.dependencies, { '@hm-design/org-chart': '0.1.0' });
|
||||
assert.deepEqual(manifest.peerDependencies, { react: '^19.0.0' });
|
||||
const bundle = await readFile(require.resolve('@hm-design/org-chart-react'), 'utf8');
|
||||
assert.ok(bundle.startsWith('"use client";'));
|
||||
for (const dependency of ['react', 'react/jsx-runtime', '@hm-design/org-chart']) assert.ok(bundle.includes(`from "${dependency}"`) || bundle.includes(`from '${dependency}'`));
|
||||
assert.ok(!bundle.includes('ManagedOrgChart') && !bundle.includes('validateOrgChartDataset') && !bundle.includes('JsonOrgChartDataSource'));
|
||||
await writeFile(path.join(consumer, 'consumer.tsx'), `import { OrgChart, type OrgChartProps, type OrgChartDataSource, type OrgChartState, type OrgChartDataset } from '@hm-design/org-chart-react';
|
||||
import { JsonOrgChartDataSource } from '@hm-design/org-chart';
|
||||
declare const data: OrgChartDataset;
|
||||
const source: OrgChartDataSource = new JsonOrgChartDataSource(data);
|
||||
const props: OrgChartProps = { dataSource: source, stylesheetUrls: { engine: './engine.css', core: './style.css' }, onStateChange(state: OrgChartState) { if(state.status === 'ready') void state.summary.raw.total; }, onLifecycleError(error: unknown) { void error; } };
|
||||
export const consumer = <OrgChart {...props} />;
|
||||
// @ts-expect-error dataSource is a required Core source interface
|
||||
export const missingSource = <OrgChart stylesheetUrls={{engine:'engine.css',core:'style.css'}} />;
|
||||
// @ts-expect-error wrapper does not expose engine controls
|
||||
export const unsupportedZoom = <OrgChart {...props} zoom />;
|
||||
// @ts-expect-error Core-owned container cannot be supplied through React Props
|
||||
export const unsupportedContainer = <OrgChart {...props} container={document.body} />;
|
||||
`);
|
||||
await writeFile(path.join(consumer, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', jsx: 'react-jsx', strict: true, noEmit: true, skipLibCheck: false, types: ['react'] }, include: ['consumer.tsx'] }));
|
||||
const localRequire = createRequire(import.meta.url);
|
||||
const typescriptManifest = localRequire.resolve('typescript/package.json');
|
||||
const typescript = JSON.parse(await readFile(typescriptManifest, 'utf8'));
|
||||
execFileSync(process.execPath, [path.resolve(path.dirname(typescriptManifest), typescript.bin.tsc), '-p', path.join(consumer, 'tsconfig.json')], { stdio: 'inherit' });
|
||||
const report = {
|
||||
status: 'PASS',
|
||||
checks: ['Actual tarball runtime/public export without DOM', 'Strict NodeNext declarations using extracted Core/Wrapper', 'Required source and unsupported Props type boundaries', 'React/Core remain external', 'ESM client boundary retained'],
|
||||
limitation: 'React and compiler supplied by verification environment; fresh remote-registry dependency installation and SSR/hydration are not verified.',
|
||||
};
|
||||
await writeFile(path.join(consumer, 'report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify(report));
|
||||
@@ -0,0 +1,312 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const output = path.join(root, 'test-results/org-chart-react');
|
||||
await mkdir(output, { recursive: true });
|
||||
const modulePath = process.env.ORGCHART_PLAYWRIGHT_MODULE;
|
||||
const { chromium } = await import(modulePath ? pathToFileURL(modulePath).href : 'playwright');
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.ttf': 'font/ttf', '.woff2': 'font/woff2', '.svg': 'image/svg+xml' };
|
||||
async function serve(directory) {
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
|
||||
const relative = path.relative(directory, file);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; }
|
||||
response.setHeader('Content-Type', mime[path.extname(file)] || 'application/octet-stream');
|
||||
response.end(await readFile(file));
|
||||
} catch { response.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
return { server, origin: `http://127.0.0.1:${server.address().port}` };
|
||||
}
|
||||
const consumer = await serve(path.join(root, 'examples/org-chart-react/dist'));
|
||||
const vanilla = await serve(path.join(root, 'examples/org-chart/dist'));
|
||||
const storybook = await serve(path.join(root, 'storybook-static'));
|
||||
const browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
|
||||
const results = [], errors = [], metrics = [], snapshots = {};
|
||||
function check(condition, name, detail) {
|
||||
results.push({ name, status: condition ? 'PASS' : 'FAIL', detail });
|
||||
assert.ok(condition, `${name}: ${JSON.stringify(detail)}`);
|
||||
}
|
||||
function captureErrors(page, transport) {
|
||||
page.on('pageerror', error => errors.push({ transport, type: 'pageerror', message: error.message }));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push({ transport, type: 'console', message: message.text() }); });
|
||||
}
|
||||
async function snapshot(page, selector = '.hm-orgchart-host') {
|
||||
return page.locator(selector).first().evaluate(host => {
|
||||
const shadow = host.shadowRoot;
|
||||
const nodes = [...shadow.querySelectorAll('.node')];
|
||||
return {
|
||||
organizations: nodes.map(node => ({
|
||||
id: node.dataset.organizationId,
|
||||
parentId: node.parentElement.parentElement.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId || null,
|
||||
employees: [...node.querySelectorAll('.hm-orgchart-employee')].map(employee => employee.dataset.employeeId),
|
||||
text: node.querySelector('.hm-orgchart-organization').textContent,
|
||||
})),
|
||||
summary: shadow.querySelector('.hm-orgchart-summary')?.textContent,
|
||||
heading: shadow.querySelector('.hm-orgchart-heading')?.textContent,
|
||||
css: [...shadow.querySelectorAll('link')].map(link => ({ href: link.href, loaded: !!link.sheet })),
|
||||
style: (() => { const s = getComputedStyle(shadow.querySelector('.hm-orgchart-organization')); return { color: s.color, background: s.backgroundColor, font: s.fontFamily, radius: s.borderRadius }; })(),
|
||||
};
|
||||
});
|
||||
}
|
||||
async function ready(page, instance = 'A', version) {
|
||||
await page.waitForFunction(({ instance, version }) => {
|
||||
const event = window.OrgChartReactDemo.events.filter(event => event.instance === instance).at(-1);
|
||||
const host = document.querySelector(`[data-instance="${instance}"] .hm-orgchart-host`);
|
||||
return !!host?.shadowRoot.querySelector('.hm-orgchart-shell[aria-busy="false"] .node') && event?.state.status === 'ready' && (!version || event.state.dataVersion === version);
|
||||
}, { instance, version });
|
||||
}
|
||||
async function patch(page, value) { await page.evaluate(value => window.OrgChartReactDemo.render(value), value); }
|
||||
async function source(page, name, instance = 'A') {
|
||||
await page.evaluate(({ name, instance }) => window.OrgChartReactDemo.render({ [`source${instance}`]: window.OrgChartReactDemo.sources[name] }), { name, instance });
|
||||
}
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
|
||||
await context.addInitScript(() => {
|
||||
const NativeObserver = window.MutationObserver;
|
||||
const observers = new Map();
|
||||
window.MutationObserver = class extends NativeObserver {
|
||||
observe(...args) { observers.set(this, args[0]); return super.observe(...args); }
|
||||
disconnect() { observers.delete(this); return super.disconnect(); }
|
||||
};
|
||||
window.chartObservers = () => [...observers.values()].filter(target => target.getRootNode()?.host?.classList.contains('hm-orgchart-host')).length;
|
||||
});
|
||||
const html = await context.newPage();
|
||||
captureErrors(html, 'vanilla-http');
|
||||
await html.goto(`${vanilla.origin}/index.html`);
|
||||
await html.waitForFunction(() => window.orgChartReady);
|
||||
snapshots.htmlV1 = await snapshot(html);
|
||||
const page = await context.newPage();
|
||||
captureErrors(page, 'react-production');
|
||||
if (process.env.ORGCHART_CONSUMER_URL) {
|
||||
const production = await context.newPage(); captureErrors(production, 'react-production');
|
||||
await production.goto(consumer.origin); await ready(production, 'A', 'mock-1');
|
||||
snapshots.reactProduction = await snapshot(production);
|
||||
check(JSON.stringify(snapshots.reactProduction.organizations) === JSON.stringify(snapshots.htmlV1.organizations) && snapshots.reactProduction.css.every(link => link.loaded), 'independent production React Consumer: public imports, assets and identical HTML nodes');
|
||||
await production.close();
|
||||
}
|
||||
await page.goto(process.env.ORGCHART_CONSUMER_URL || consumer.origin);
|
||||
await ready(page, 'A', 'mock-1');
|
||||
snapshots.reactV1 = await snapshot(page);
|
||||
check(JSON.stringify(snapshots.reactV1.organizations) === JSON.stringify(snapshots.htmlV1.organizations), 'HTML/React v1 hierarchy, employee placement and identical node UI', snapshots.reactV1.organizations);
|
||||
check(snapshots.reactV1.summary === snapshots.htmlV1.summary && snapshots.reactV1.heading === snapshots.htmlV1.heading, 'HTML/React v1 identical summary and presentation');
|
||||
check(JSON.stringify(snapshots.reactV1.style) === JSON.stringify(snapshots.htmlV1.style), 'HTML/React identical computed theme', snapshots.reactV1.style);
|
||||
check(snapshots.reactV1.css.length === 2 && snapshots.reactV1.css.every(link => link.loaded), 'production Shadow DOM CSS assets loaded', snapshots.reactV1.css);
|
||||
const initial = await page.evaluate(() => ({ events: window.OrgChartReactDemo.events, requests: window.OrgChartReactDemo.requests, observers: window.chartObservers() }));
|
||||
const expectedLoads = process.env.ORGCHART_CONSUMER_URL ? 2 : 1;
|
||||
check(initial.requests.v1.loads === expectedLoads && initial.events.filter(event => event.state.status === 'ready').length === 1, 'Strict Mode: expected initial load count and single ready (development replay; production single setup)', initial);
|
||||
check(await page.locator('.hm-orgchart-host').count() === 1, 'Strict Mode: exactly one active Core host');
|
||||
const hostId = await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance');
|
||||
await patch(page, { callback: 2, className: 'consumer-chart changed' });
|
||||
await page.waitForFunction(() => document.querySelector('.consumer-chart.changed'));
|
||||
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId && (await page.evaluate(() => window.OrgChartReactDemo.requests.v1.loads)) === expectedLoads, 'callback/className-only render retains Core and does not reload');
|
||||
await source(page, 'v2');
|
||||
await ready(page, 'A', 'mock-2');
|
||||
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId, 'source identity uses reload on the same Core host');
|
||||
const stateV2 = await page.evaluate(() => window.OrgChartReactDemo.events.at(-1));
|
||||
check(stateV2.callback === 2 && stateV2.state.summary.raw.total === 5 && stateV2.state.summary.raw.byOrganization['team-a'].direct === 1 && stateV2.state.summary.raw.byOrganization['team-b'].direct === 3, 'latest callback and v2 expected 1/1/3 placement, total 5', stateV2);
|
||||
await html.evaluate(() => window.OrgChartDemo.update());
|
||||
snapshots.htmlV2 = await snapshot(html);
|
||||
snapshots.reactV2 = await snapshot(page);
|
||||
check(JSON.stringify(snapshots.reactV2.organizations) === JSON.stringify(snapshots.htmlV2.organizations) && snapshots.reactV2.summary === snapshots.htmlV2.summary, 'HTML/React v2 data, hierarchy, employee nodes and aggregation parity');
|
||||
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
await page.setViewportSize({ width, height: 1100 });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const metric = await page.locator('.hm-orgchart-host').evaluate(host => {
|
||||
const shadow = host.shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport');
|
||||
const rectangles = [...shadow.querySelectorAll('.node')].map(node => node.getBoundingClientRect());
|
||||
const overlap = rectangles.some((a, index) => rectangles.slice(index + 1).some(b => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1));
|
||||
const organization = shadow.querySelector('.hm-orgchart-organization'), style = getComputedStyle(organization);
|
||||
const probe = document.createElement('div');
|
||||
probe.style.color = 'var(--hm-sem-fg-primary)'; probe.style.background = 'var(--hm-sem-bg-surface)'; probe.style.borderRadius = 'var(--hm-radius-container)'; shadow.append(probe);
|
||||
const expectedStyle = getComputedStyle(probe), expected = { color: expectedStyle.color, background: expectedStyle.backgroundColor, radius: expectedStyle.borderRadius };
|
||||
const match = expected.color === style.color && expected.background === style.backgroundColor && expected.radius === style.borderRadius;
|
||||
probe.remove();
|
||||
const nodes = [...shadow.querySelectorAll('.node')], rect = nodes[0].getBoundingClientRect();
|
||||
const centers = nodes.slice(1).map(node => { const r = node.getBoundingClientRect(); return r.x + r.width / 2; });
|
||||
return {
|
||||
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
|
||||
host: { clientWidth: host.clientWidth, scrollWidth: host.scrollWidth, clientHeight: host.clientHeight, scrollHeight: host.scrollHeight },
|
||||
viewport: { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight, overflowX: getComputedStyle(viewport).overflowX },
|
||||
overlap, themeMatch: match, expected, color: style.color, background: style.backgroundColor, radius: style.borderRadius, font: style.fontFamily,
|
||||
fontLoaded: document.fonts.check('13px "Noto Sans KR"'), idWhiteSpace: getComputedStyle(shadow.querySelector('.hm-orgchart-domain-id')).whiteSpace,
|
||||
connectorsAligned: Math.abs(rect.x + rect.width / 2 - (centers[0] + centers[1]) / 2) < 2,
|
||||
connectorTheme: getComputedStyle(nodes[1], '::before').backgroundColor === getComputedStyle(viewport).borderTopColor,
|
||||
};
|
||||
});
|
||||
metrics.push({ width, ...metric });
|
||||
check(metric.document.scrollWidth <= width && metric.host.scrollWidth <= metric.host.clientWidth && metric.viewport.overflowX === 'auto' && !metric.overlap, `${width}px container overflow/scroll/no overlap`, metric);
|
||||
check(metric.themeMatch && metric.font.includes('Noto Sans KR') && metric.fontLoaded && metric.idWhiteSpace === 'nowrap' && metric.connectorsAligned && metric.connectorTheme, `${width}px theme/fonts/IDs/connectors`, metric);
|
||||
await page.screenshot({ path: path.join(output, `react-consumer-${width}.png`), fullPage: true });
|
||||
}
|
||||
const toggle = page.locator('.hm-orgchart-toggle').first();
|
||||
await toggle.focus(); await toggle.press('Enter');
|
||||
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding'));
|
||||
check(await toggle.getAttribute('aria-expanded') === 'false', 'React keyboard Enter collapses');
|
||||
await toggle.press('Space');
|
||||
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding'));
|
||||
check(await toggle.getAttribute('aria-expanded') === 'true', 'React keyboard Space expands');
|
||||
await source(page, 'v1'); await ready(page, 'A', 'mock-1');
|
||||
check(await page.locator('.hm-orgchart-viewport').evaluate(element => element.getRootNode().activeElement === element), 'source reload restores removed keyboard focus to stable viewport');
|
||||
|
||||
await page.evaluate(() => window.OrgChartReactDemo.render({ sourceA: window.OrgChartReactDemo.slowSource }));
|
||||
await page.waitForFunction(() => window.OrgChartReactDemo.requests.slow.loads === 1);
|
||||
await source(page, 'v2'); await ready(page, 'A', 'mock-2');
|
||||
const beforeLate = await page.evaluate(() => window.OrgChartReactDemo.events.length);
|
||||
await page.evaluate(() => window.OrgChartReactDemo.releaseSlow());
|
||||
await page.waitForTimeout(100);
|
||||
check(await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.dataVersion === 'mock-2' && window.OrgChartReactDemo.requests.slow.aborts === 1) && (await page.evaluate(() => window.OrgChartReactDemo.events.length)) === beforeLate, 'superseded abort-ignoring source cannot update DOM/callback');
|
||||
await source(page, 'error');
|
||||
await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1).state.status === 'error');
|
||||
check(await page.locator('.hm-orgchart-issues').count() === 1, 'source failure controlled error state');
|
||||
await source(page, 'v1'); await ready(page, 'A', 'mock-1');
|
||||
check(await page.locator('.hm-orgchart-issues').count() === 0, 'Error to Ready recovery via source replacement');
|
||||
await source(page, 'zeroEmployees'); await ready(page);
|
||||
check((await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.summary.raw.total)) === 0, 'zero employees remains Ready with total 0');
|
||||
await source(page, 'zeroOrganizations');
|
||||
await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1).state.status === 'error');
|
||||
check(await page.locator('.hm-orgchart-issues').count() === 1, 'zero organizations is Error; no fabricated empty state');
|
||||
await source(page, 'v1'); await ready(page, 'A', 'mock-1');
|
||||
await page.evaluate(() => window.OrgChartReactDemo.render({ aggregationPolicy: { policyVersion: 'consumer-test', includedStatuses: ['active'], activeStatuses: ['active'], includeLeaders: true, membershipMode: 'primaryOnly' } }));
|
||||
await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1).state.aggregation?.status === 'ready');
|
||||
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') !== hostId && (await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.aggregation.counts.total)) === 2, 'policy change safely recreates Core and uses explicit test policy');
|
||||
let currentHost = await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance');
|
||||
await page.evaluate(() => window.OrgChartReactDemo.render({ stylesheetUrls: { ...window.OrgChartReactDemo.configuration.stylesheetUrls } }));
|
||||
await page.waitForTimeout(70);
|
||||
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === currentHost, 'equal stylesheet URL values retain Core');
|
||||
await page.evaluate(() => { const urls = window.OrgChartReactDemo.configuration.stylesheetUrls; window.OrgChartReactDemo.render({ stylesheetUrls: { engine: urls.engine + '?consumer=2', core: urls.core }, aggregationPolicy: undefined }); });
|
||||
await page.waitForFunction(oldId => document.querySelector('.hm-orgchart-host')?.dataset.orgchartInstance !== oldId, currentHost);
|
||||
await ready(page);
|
||||
check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') !== currentHost && (await snapshot(page)).css.every(link => link.loaded), 'changed stylesheet URL safely recreates and loads assets');
|
||||
|
||||
await patch(page, { mountedB: true }); await ready(page, 'B', 'mock-2');
|
||||
const bHost = await page.locator('[data-instance="B"] .hm-orgchart-host').getAttribute('data-orgchart-instance');
|
||||
const bSnapshot = await snapshot(page, '[data-instance="B"] .hm-orgchart-host');
|
||||
await source(page, 'v2'); await ready(page, 'A', 'mock-2');
|
||||
check(await page.locator('.hm-orgchart-host').count() === 2 && JSON.stringify(await snapshot(page, '[data-instance="B"] .hm-orgchart-host')) === JSON.stringify(bSnapshot), 'two simultaneous instances; A update preserves B');
|
||||
await page.evaluate(() => { document.querySelector('[data-instance="A"]').style.setProperty('--hm-sem-bg-surface', getComputedStyle(document.documentElement).getPropertyValue('--hm-sem-bg-content')); });
|
||||
check((await snapshot(page, '[data-instance="A"] .hm-orgchart-host')).style.background !== bSnapshot.style.background && (await snapshot(page, '[data-instance="B"] .hm-orgchart-host')).style.background === bSnapshot.style.background, 'scoped existing Theme variable change inherits through Shadow DOM and isolates B');
|
||||
await patch(page, { mountedA: false });
|
||||
await page.waitForFunction(() => !document.querySelector('[data-instance="A"]'));
|
||||
check(await page.locator('.hm-orgchart-host').count() === 1 && await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === bHost, 'A unmount preserves B and its Core identity');
|
||||
const bObservers = await page.evaluate(() => window.chartObservers());
|
||||
for (let iteration = 0; iteration < 5; iteration++) {
|
||||
await patch(page, { mountedA: true }); await ready(page, 'A', 'mock-2');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.hm-orgchart-host').length === 2);
|
||||
await patch(page, { mountedA: false });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.hm-orgchart-host').length === 1);
|
||||
}
|
||||
check(await page.evaluate(() => window.chartObservers()) === bObservers, 'five Strict Mode mount/unmount cycles preserve B observer baseline');
|
||||
await patch(page, { mountedA: true }); await ready(page, 'A', 'mock-2');
|
||||
await page.evaluate(() => window.OrgChartReactDemo.render({ sourceA: window.OrgChartReactDemo.slowSource }));
|
||||
await page.waitForFunction(() => window.OrgChartReactDemo.requests.slow.loads === 2);
|
||||
await patch(page, { mountedA: false });
|
||||
await page.waitForFunction(() => !document.querySelector('[data-instance="A"]'));
|
||||
const unmountEvents = await page.evaluate(() => window.OrgChartReactDemo.events.length);
|
||||
await page.evaluate(() => window.OrgChartReactDemo.releaseSlow()); await page.waitForTimeout(100);
|
||||
check((await page.evaluate(() => window.OrgChartReactDemo.events.length)) === unmountEvents, 'late response after unmount cannot emit callback');
|
||||
check((await page.evaluate(() => window.OrgChartReactDemo.lifecycleErrors.length)) === 0, 'normal lifecycle has no rejected-use errors');
|
||||
await patch(page, { mountedA: true, stylesheetUrls: { engine: '', core: '' } });
|
||||
await page.waitForFunction(() => window.OrgChartReactDemo.lifecycleErrors.length > 0);
|
||||
check(await page.locator('.hm-orgchart-host').count() === 0, 'invalid Core configuration is reported via lifecycle error without leaked hosts');
|
||||
await page.evaluate(() => window.OrgChartReactDemo.destroy());
|
||||
check(await page.locator('.hm-orgchart-host').count() === 0 && await page.evaluate(() => window.chartObservers()) === 0, 'terminal React root unmount removes all Core DOM/observers');
|
||||
|
||||
const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8'));
|
||||
const stories = Object.values(index.entries).filter(entry => entry.title === 'OrgChart/Content' && entry.type === 'story');
|
||||
check(stories.length === 8, 'production Storybook registers exactly eight OrgChart stories', stories.map(story => ({ id: story.id, name: story.name })));
|
||||
const sb = await context.newPage(); captureErrors(sb, 'storybook-production');
|
||||
for (const story of stories) {
|
||||
await sb.goto(`${storybook.origin}/iframe.html?id=${story.id}&viewMode=story`);
|
||||
await sb.waitForFunction(() => [...document.querySelectorAll('.hm-orgchart-host')].some(host => host.shadowRoot.querySelector('.hm-orgchart-organization, .hm-orgchart-issues, .hm-orgchart-state:not(:empty)')));
|
||||
if (story.id.includes('loading')) {
|
||||
check((await sb.locator('.hm-orgchart-state').allTextContents()).some(text => text.includes('불러오는')), `${story.name}: actual loading source`);
|
||||
} else {
|
||||
await sb.waitForFunction(() => [...document.querySelectorAll('.hm-orgchart-host')].every(host => host.shadowRoot.querySelector('.hm-orgchart-organization')));
|
||||
check(await sb.locator('.node').count() >= 3, `${story.name}: real Core nodes rendered`);
|
||||
}
|
||||
if (story.id.includes('json-data-update')) {
|
||||
await sb.getByRole('button', { name: 'JSON v2', exact: true }).click();
|
||||
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2'));
|
||||
check((await sb.locator('.hm-orgchart-raw-total').textContent()).includes('5명'), 'Storybook JSON v1 to v2 recalculates 5 employees');
|
||||
await sb.getByRole('button', { name: 'Unmount', exact: true }).click();
|
||||
check(await sb.locator('.hm-orgchart-host').count() === 0, 'Storybook Unmount removes Core');
|
||||
await sb.getByRole('button', { name: 'Mount', exact: true }).click();
|
||||
await sb.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.node'));
|
||||
check(await sb.locator('.hm-orgchart-host').count() === 1, 'Storybook remount creates one Core');
|
||||
}
|
||||
if (story.id.includes('loading')) {
|
||||
await sb.getByRole('button', { name: 'Source Error', exact: true }).click();
|
||||
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('error'));
|
||||
check(await sb.locator('.hm-orgchart-issues').count() === 1, 'Storybook source error controlled by Core');
|
||||
await sb.getByRole('button', { name: 'Ready v2 / 복구', exact: true }).click();
|
||||
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2'));
|
||||
check(await sb.locator('.node').count() === 3, 'Storybook Error to Ready recovery');
|
||||
await sb.getByRole('button', { name: '직원 0명 (Ready)', exact: true }).click();
|
||||
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('story-zero-employees'));
|
||||
check((await sb.locator('.hm-orgchart-raw-total').textContent()).includes('0명'), 'Storybook zero employees Ready');
|
||||
await sb.getByRole('button', { name: '조직 0개 (Error)', exact: true }).click();
|
||||
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('error'));
|
||||
check(await sb.locator('.hm-orgchart-issues').count() === 1, 'Storybook zero organizations Error');
|
||||
await sb.getByRole('button', { name: '느린 v1 (취소 무시)', exact: true }).click();
|
||||
await sb.getByRole('button', { name: 'Ready v2 / 복구', exact: true }).click();
|
||||
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2'));
|
||||
const outputBefore = await sb.locator('output').textContent();
|
||||
await sb.waitForTimeout(4100);
|
||||
check(await sb.locator('output').textContent() === outputBefore, 'Storybook stale abort-ignoring response produces no callback');
|
||||
}
|
||||
if (story.id.includes('multiple-instances')) {
|
||||
const b = sb.getByRole('region', { name: 'OrgChart B', exact: true });
|
||||
const bIdentity = await b.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance');
|
||||
const bState = await b.locator('output').textContent();
|
||||
await sb.getByRole('button', { name: 'A → JSON v2', exact: true }).click();
|
||||
await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2'));
|
||||
await sb.getByRole('button', { name: 'A Unmount', exact: true }).click();
|
||||
check(await sb.locator('.hm-orgchart-host').count() === 1 && await b.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === bIdentity && await b.locator('output').textContent() === bState, 'Storybook A update/unmount leaves B identity, data and callback count unchanged');
|
||||
}
|
||||
if (story.id.includes('basic-organization') || story.id.includes('employee-cards') || story.id.includes('responsive-container')) {
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
await sb.setViewportSize({ width, height: 1100 });
|
||||
if (story.id.includes('responsive-container')) await sb.getByRole('button', { name: `${width}px`, exact: true }).click();
|
||||
const dimensions = await sb.evaluate(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
const nodes = [...shadow.querySelectorAll('.node')], viewport = shadow.querySelector('.hm-orgchart-viewport');
|
||||
const rects = nodes.map(node => node.getBoundingClientRect());
|
||||
return {
|
||||
clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth,
|
||||
viewport: { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight },
|
||||
nodeWidths: rects.map(rect => rect.width),
|
||||
overlap: rects.some((a, index) => rects.slice(index + 1).some(b => Math.min(a.right,b.right)-Math.max(a.left,b.left)>1 && Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top)>1)),
|
||||
avatars: [...shadow.querySelectorAll('.hm-orgchart-avatar')].every(avatar => avatar.textContent && !avatar.querySelector('img')),
|
||||
textFits: [...shadow.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-organization-name')].every(element => element.scrollWidth <= element.clientWidth),
|
||||
};
|
||||
});
|
||||
metrics.push({ story: story.name, width, ...dimensions });
|
||||
check(dimensions.scrollWidth <= width && !dimensions.overlap && dimensions.textFits && dimensions.avatars, `${story.name}: ${width}px document scroll/overlap/long text/profile fallback`, dimensions);
|
||||
await sb.screenshot({ path: path.join(output, `${story.id}-${width}.png`), fullPage: true });
|
||||
}
|
||||
}
|
||||
await sb.screenshot({ path: path.join(output, `${story.id}.png`), fullPage: true });
|
||||
}
|
||||
if (process.env.ORGCHART_STORYBOOK_URL) {
|
||||
await sb.goto(`${process.env.ORGCHART_STORYBOOK_URL}/iframe.html?id=orgchart-content--basic-organization&viewMode=story`);
|
||||
await sb.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.hm-orgchart-organization'));
|
||||
check(await sb.locator('.node').count() === 3, 'development Storybook on reserved separate port executes real Wrapper');
|
||||
}
|
||||
check(errors.length === 0, 'browser console/runtime errors are zero across consumer, Vanilla and Storybook', errors);
|
||||
} catch (error) {
|
||||
results.push({ name: 'verification completion', status: 'FAIL', detail: error.stack });
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await writeFile(path.join(output, 'report.json'), JSON.stringify({ browser: await browser.version(), results, errors, metrics, snapshots }, null, 2));
|
||||
await browser.close();
|
||||
for (const { server } of [consumer, vanilla, storybook]) await new Promise(resolve => server.close(resolve));
|
||||
console.log(JSON.stringify({ pass: results.filter(result => result.status === 'PASS').length, fail: results.filter(result => result.status === 'FAIL').length, errors, report: path.join(output, 'report.json') }));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
root: path.resolve(import.meta.dirname),
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
server: { host: '127.0.0.1', port: 6026, strictPort: true },
|
||||
build: { outDir: 'dist', emptyOutDir: true, rolldownOptions: { input: { index: path.resolve(import.meta.dirname, 'index.html'), p1: path.resolve(import.meta.dirname, 'p1.html') } } },
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export function startDemo(api, inlineFixtures) {
|
||||
const { createOrgChart, JsonOrgChartDataSource } = api;
|
||||
const container = document.getElementById('chart-host');
|
||||
const status = document.getElementById('status');
|
||||
const stylesheetUrls = {
|
||||
engine: new URL('./assets/engine.css', location.href).href,
|
||||
core: new URL('./assets/style.css', location.href).href,
|
||||
};
|
||||
const source = () => new JsonOrgChartDataSource(inlineFixtures ? inlineFixtures.initial : './data-v1.json');
|
||||
let chart;
|
||||
function create() {
|
||||
chart?.destroy();
|
||||
chart = createOrgChart({ container, dataSource: source(), stylesheetUrls, onStateChange(state) {
|
||||
status.textContent = state.status === 'ready' ? `Ready · ${state.dataVersion}`
|
||||
: state.status === 'error' ? `Error · ${state.issues.map(issue => issue.code).join(', ')}` : state.status;
|
||||
} });
|
||||
return chart.mount();
|
||||
}
|
||||
async function update() {
|
||||
const data = inlineFixtures ? inlineFixtures.changed : await (await fetch('./data-v2.json')).json();
|
||||
await chart.update(data);
|
||||
}
|
||||
function destroy() { chart.destroy(); status.textContent = 'Destroyed · 새 인스턴스로 다시 생성할 수 있습니다.'; }
|
||||
const actions = { reload: () => chart.reload(), update, destroy, recreate: create };
|
||||
for (const [id, action] of Object.entries(actions)) document.getElementById(id).addEventListener('click', () => {
|
||||
Promise.resolve().then(action).catch(() => { status.textContent = 'Core 호출 순서를 확인하세요.'; });
|
||||
});
|
||||
window.addEventListener('pagehide', () => chart.destroy(), { once: true });
|
||||
// This example's inspection seam uses only public Core APIs, never engine internals.
|
||||
window.OrgChartDemo = { api, container, stylesheetUrls, source, create, update, destroy, get chart() { return chart; } };
|
||||
create().then(() => { window.orgChartReady = true; }).catch(() => { status.textContent = 'Mount failed'; });
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Read browser PNG backgrounds beside text; never edit production images.
|
||||
|
||||
Sample positions cover DOM text line bounds, with a clear padding-column probe
|
||||
for the current vertical opaque gradients. Pixel sampling and the sRGB CSS model
|
||||
are distinct evidence; unsupported/clipped painting is NOT VERIFIED.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OUT = ROOT / 'test-results' / 'org-chart-quality'
|
||||
report = json.loads((OUT / (sys.argv[1] if len(sys.argv) > 1 else 'browser.json')).read_text(encoding='utf-8'))
|
||||
|
||||
def rgb(value):
|
||||
match = re.fullmatch(r'rgba?\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)(?:,\s*(1))?\)', value)
|
||||
return tuple(map(float, match.groups()[:3])) if match else None
|
||||
|
||||
def luminance(color):
|
||||
def linear(channel):
|
||||
encoded = channel / 255
|
||||
return encoded / 12.92 if encoded <= .04045 else ((encoded + .055) / 1.055) ** 2.4
|
||||
return sum(weight * linear(channel) for weight, channel in zip((.2126, .7152, .0722), color))
|
||||
|
||||
def contrast(first, second):
|
||||
a, b = sorted((luminance(first), luminance(second)))
|
||||
return (b + .05) / (a + .05)
|
||||
|
||||
evidence = []
|
||||
for measurement in report['measurements']:
|
||||
image = Image.open(OUT / measurement['screenshot']).convert('RGB')
|
||||
for item in measurement['colors']:
|
||||
foreground = rgb(item['color'])
|
||||
behind = item['behind']
|
||||
entry = dict(label=measurement['label'], width=measurement['width'], selector=item['className'],
|
||||
organizationId=item.get('organizationId'), variant=item.get('variant'), target=item.get('target'),
|
||||
foreground=item['color'], fontSize=item['fontSize'], fontWeight=item['fontWeight'])
|
||||
threshold = 3 if item['fontSize'] >= 24 or (float(item['fontWeight']) >= 700 and item['fontSize'] >= 14 * 96 / 72) else 4.5
|
||||
entry['threshold'] = threshold
|
||||
if not foreground or not behind:
|
||||
entry.update(status='NOT VERIFIED', reason='Unknown foreground/background composition')
|
||||
elif behind['gradient'] == 'none':
|
||||
background = rgb(behind['background'])
|
||||
if background:
|
||||
value = contrast(foreground, background)
|
||||
entry.update(status='PASS' if value >= threshold else 'REVIEW', method='computed opaque solid', ratio=value, background=behind['background'])
|
||||
else:
|
||||
entry.update(status='NOT VERIFIED', reason='Transparent/unsupported solid composition')
|
||||
else:
|
||||
stops = re.findall(r'rgb\((\d+),\s*(\d+),\s*(\d+)\)\s+(0|100)%', behind['gradient'])
|
||||
box, lines, clip = behind['box'], item['ranges'], item.get('clip')
|
||||
angle = re.search(r'linear-gradient\(([^r][^,]*),', behind['gradient'])
|
||||
if len(stops) != 2 or not lines or (angle and angle.group(1).strip() != '180deg'):
|
||||
entry.update(status='NOT VERIFIED', reason='Unsupported gradient/text range')
|
||||
else:
|
||||
colors = [tuple(map(int, stop[:3])) for stop in stops]
|
||||
# Probe an empty padding column, inside corners and before text.
|
||||
text_left = min(line['x'] for line in lines)
|
||||
x = math.floor(min(text_left - 2, box['x'] + max(4, (text_left - box['x']) / 2)))
|
||||
samples, deltas = [], []
|
||||
for line in lines:
|
||||
for y in range(math.ceil(line['y']), math.floor(line['y'] + line['height']) + 1):
|
||||
if not (0 <= x < image.width and 0 <= y < image.height):
|
||||
continue
|
||||
if clip and not (clip['x'] + 1 <= x < clip['x'] + clip['width'] - 1 and clip['y'] + 1 <= y < clip['y'] + clip['height'] - 1):
|
||||
continue
|
||||
offset = (y + .5 - box['y']) / box['height']
|
||||
if not 0 <= offset <= 1:
|
||||
continue
|
||||
pixel = image.getpixel((x, y))
|
||||
modeled = tuple(a + (b - a) * offset for a, b in zip(*colors))
|
||||
delta = max(abs(a - b) for a, b in zip(pixel, modeled))
|
||||
deltas.append(delta)
|
||||
samples.append(dict(x=x, y=y, background=pixel, contrast=contrast(foreground, pixel), modelDelta=delta))
|
||||
if not samples or max(deltas) > 2:
|
||||
entry.update(status='NOT VERIFIED', reason='Probe clipped, overpainted or disagrees with supported sRGB painting model', sampled=len(samples), maxModelDelta=max(deltas) if deltas else None)
|
||||
else:
|
||||
minimum = min(samples, key=lambda sample: sample['contrast'])
|
||||
entry.update(status='PASS' if minimum['contrast'] >= threshold else 'REVIEW', method='PNG background beside actual DOM text lines',
|
||||
ratio=minimum['contrast'], sampled=len(samples), maxModelDelta=max(deltas), weakest=minimum,
|
||||
conservativeStopMinimum=min(contrast(foreground, color) for color in colors))
|
||||
evidence.append(entry)
|
||||
|
||||
result = dict(method='DPR1 Chrome PNG; opaque foreground from computed CSS; padding-column gradient probes over DOM Range line bounds',
|
||||
thresholds='Unrounded 4.5 normal / 3 large; no logo recoloring; no full WCAG certification',
|
||||
results=evidence, statusCounts={status: sum(item['status'] == status for item in evidence) for status in ('PASS', 'REVIEW', 'NOT VERIFIED')})
|
||||
(OUT / (sys.argv[2] if len(sys.argv) > 2 else 'contrast-pixels.json')).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps(result['statusCounts']))
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HM OrgChart · 직접 열기 예제</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="./assets/hm-theme.css"><link rel="stylesheet" href="./demo.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>OrgChart Core · 직접 열기 예제</h1>
|
||||
<p>가상 JSON을 Classic 번들로 실행합니다. file://에서 외부 JSON Fetch는 사용하지 않습니다.</p>
|
||||
<div class="demo-controls" aria-label="Core 실행 예제">
|
||||
<button id="reload" type="button">초기 JSON 재로드</button>
|
||||
<button id="update" type="button">직원 이동·추가 반영</button>
|
||||
<button id="destroy" type="button">인스턴스 정리</button>
|
||||
<button id="recreate" type="button">새 인스턴스 생성</button>
|
||||
</div>
|
||||
<p id="status" role="status" aria-live="polite">준비 중</p><div id="chart-host"></div>
|
||||
</main>
|
||||
<script src="./assets/orgchart.browser.js"></script><script src="./fixtures.js"></script>
|
||||
<script src="./app-classic.js"></script>
|
||||
<script>startDemo(window.HMOrgChart, window.OrgChartFixtures);</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Extract business-management PNG colors into matching vector card templates.
|
||||
|
||||
Reads the reference only. No bitmap is embedded in the generated SVGs.
|
||||
Usage: python create-business-card-assets.py <reference PNG path>
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
reference = Path(sys.argv[1])
|
||||
image = Image.open(reference).convert('RGBA')
|
||||
pixels = np.asarray(image, dtype=float)
|
||||
width, height = image.size
|
||||
scale = np.array([width / 146, height / 68])
|
||||
start = np.array([124.527, 13.2139]) * scale
|
||||
end = np.array([91.4091, 70.3397]) * scale
|
||||
vector = end - start
|
||||
|
||||
# Probe only the flat outer bands, away from rounded corners and inner face.
|
||||
probes = ([(x, 2) for x in range(20, width - 20)]
|
||||
+ [(3, y) for y in range(20, height - 15)]
|
||||
+ [(width - 4, y) for y in range(20, height - 15)]
|
||||
+ [(x, height - 3) for x in range(20, width - 20)])
|
||||
coordinates = np.array(probes, dtype=float) + 0.5
|
||||
times = np.clip((coordinates - start) @ vector / (vector @ vector), 0, 1)
|
||||
values = np.array([pixels[y, x] for x, y in probes])
|
||||
basis = np.zeros((len(times), 3))
|
||||
first = times <= 0.72
|
||||
basis[first, 0] = 1 - times[first] / 0.72
|
||||
basis[first, 1] = times[first] / 0.72
|
||||
basis[~first, 1] = (1 - times[~first]) / 0.28
|
||||
basis[~first, 2] = (times[~first] - 0.72) / 0.28
|
||||
|
||||
# Test both interpolation representations rather than assuming raster alpha.
|
||||
fits = []
|
||||
for premultiplied in (False, True):
|
||||
samples = values.copy()
|
||||
if premultiplied:
|
||||
samples[:, :3] *= samples[:, 3:] / 255
|
||||
stops = np.linalg.lstsq(basis, samples, rcond=None)[0]
|
||||
predicted = basis @ stops
|
||||
if premultiplied:
|
||||
predicted[:, :3] /= np.maximum(predicted[:, 3:], 1) / 255
|
||||
stops[:, :3] /= np.maximum(stops[:, 3:], 1) / 255
|
||||
error = float(np.abs(predicted - values).mean())
|
||||
fits.append((error, premultiplied, np.clip(stops, 0, 255)))
|
||||
error, premultiplied, stops = min(fits, key=lambda item: item[0])
|
||||
|
||||
# Use plateau medians for the clamped endpoints, reducing raster edge noise.
|
||||
stops[0] = np.median(values[times == 0], axis=0)
|
||||
stops[2] = np.median(values[times == 1], axis=0)
|
||||
colors = ['#' + ''.join(f'{int(round(channel)):02X}' for channel in row[:3]) for row in stops]
|
||||
opacities = [round(float(row[3] / 255), 2) for row in stops]
|
||||
inner_top = pixels[9:35, 20:150].reshape(-1, 4)
|
||||
inner_bottom = pixels[height - 13:height - 6, 20:150].reshape(-1, 4)
|
||||
inner_colors = ['#' + ''.join(f'{int(round(channel)):02X}' for channel in row[:3])
|
||||
for row in [np.median(inner_top, axis=0), np.median(inner_bottom, axis=0)]]
|
||||
|
||||
# Recover the inner face's gradient direction from its unclamped RGB plane.
|
||||
# Colors alone are insufficient: retaining the template's gradient direction
|
||||
# would leave a visible tone difference despite matching all endpoint colors.
|
||||
inner_start_rgb = np.array([int(inner_colors[0][i:i + 2], 16) for i in (1, 3, 5)])
|
||||
inner_end_rgb = np.array([int(inner_colors[1][i:i + 2], 16) for i in (1, 3, 5)])
|
||||
delta = inner_start_rgb - inner_end_rgb
|
||||
inner_points, inner_times = [], []
|
||||
for y in range(12, height - 12, 2):
|
||||
for x in range(20, min(220, width // 2 + 16), 2):
|
||||
rgb = pixels[y, x, :3]
|
||||
t = float((inner_start_rgb - rgb) @ delta / (delta @ delta))
|
||||
if 0.06 < t < 0.94 and pixels[y, x, 3] == 255:
|
||||
inner_points.append([(x + 0.5) / scale[0], (y + 0.5) / scale[1], 1])
|
||||
inner_times.append(t)
|
||||
plane = np.linalg.lstsq(np.array(inner_points), np.array(inner_times), rcond=None)[0]
|
||||
gradient_vector = plane[:2] / (plane[:2] @ plane[:2])
|
||||
inner_start = np.array([78.0741, (-plane[2] - plane[0] * 78.0741) / plane[1]])
|
||||
inner_end = inner_start + gradient_vector
|
||||
|
||||
assets = root / 'packages/org-chart/src/assets/cards'
|
||||
for suffix in ('', '-sm'):
|
||||
template = (assets / f'경영기획팀{suffix}.svg').read_text(encoding='utf-8')
|
||||
# Retain the supplied department geometry and its size-specific gradients.
|
||||
for original, replacement in zip(['#DEE6F0', '#A8BBD1', '#577BA5'], colors):
|
||||
template = template.replace(f'stop-color="{original}"', f'stop-color="{replacement}"')
|
||||
template = template.replace(f'stop-color="{colors[2]}"', f'stop-color="{colors[2]}" stop-opacity="{opacities[2]}"')
|
||||
template = template.replace('stop-color="white"', f'stop-color="{inner_colors[0]}"')
|
||||
template = template.replace('stop-color="#E0E6F4"', f'stop-color="{inner_colors[1]}"')
|
||||
template = template.replace('paint0_linear_', 'business_management_outer_').replace('paint1_linear_', 'business_management_inner_')
|
||||
size_scale = np.array([1, 1]) if not suffix else np.array([108 / 146, 52 / 68])
|
||||
p1, p2 = inner_start * size_scale, inner_end * size_scale
|
||||
template = re.sub(r'(<linearGradient id="business_management_inner_[^"]+")[^>]+>',
|
||||
lambda match: f'{match[1]} x1="{p1[0]:.4f}" y1="{p1[1]:.4f}" x2="{p2[0]:.4f}" y2="{p2[1]:.4f}" gradientUnits="userSpaceOnUse">', template)
|
||||
template = template.replace('offset="0.97"', 'offset="1"')
|
||||
(assets / f'사업관리팀{suffix}.svg').write_text(template, encoding='utf-8')
|
||||
|
||||
output = root / 'test-results/org-chart-card-design'
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(reference, output / 'business-management-reference.png')
|
||||
evidence = {
|
||||
'reference': str(reference), 'sha256': hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
'referenceSize': [width, height], 'outerStops': colors, 'outerOpacities': opacities,
|
||||
'innerStops': inner_colors, 'outerStopOffsets': [0, 0.72, 1],
|
||||
'innerGradientStart': inner_start.tolist(), 'innerGradientEnd': inner_end.tolist(),
|
||||
'innerSampleCount': len(inner_points),
|
||||
'sampleCount': len(probes), 'fitMeanAbsoluteRGBAError': error,
|
||||
'premultipliedFit': premultiplied,
|
||||
'method': 'Outer-band RGBA samples fitted to the supplied department gradient geometry; endpoint plateau medians and inner-face medians. Inner gradient direction recovered from unclamped RGB plane. Reference PNG is preserved; geometry reuses department SVG paths.',
|
||||
}
|
||||
(output / 'business-management-colors.json').write_text(json.dumps(evidence, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
print(json.dumps(evidence, ensure_ascii=False, indent=2))
|
||||
@@ -0,0 +1,15 @@
|
||||
// One-time fixture authoring helper; normal example builds read the checked-in fixtures.
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
const document = await readFile(new URL('../../docs/org-chart/data-contract.md', import.meta.url), 'utf8');
|
||||
const snippets = [...document.matchAll(/```json\r?\n([\s\S]*?)\r?\n```/g)].map(match => JSON.parse(match[1]));
|
||||
const initial = snippets.find(value => Array.isArray(value.organizations));
|
||||
if (!initial) throw new Error('No documented fictitious dataset');
|
||||
initial.schemaVersion = '0.1-draft';
|
||||
initial.presentation.description = '가상 데이터 · 원본 집계와 업무상 총원은 구분됩니다.';
|
||||
const changed = structuredClone(initial);
|
||||
changed.metadata.dataVersion = 'mock-2';
|
||||
changed.organizations[1].name = '가상 전략기획팀';
|
||||
changed.employees.find(person => person.id === 'emp-c').organizationId = 'team-b';
|
||||
changed.employees.push({ id: 'emp-e', organizationId: 'team-b', affiliationId: 'aff-a', name: '가상직원 마', position: '책임', jobTitle: null, profileImage: null, workStatus: 'active' });
|
||||
for (const [file, value] of [['data-v1.json', initial], ['data-v2.json', changed]])
|
||||
await writeFile(new URL(file, import.meta.url), JSON.stringify(value, null, 2) + '\n');
|
||||
@@ -0,0 +1,56 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
// Organization names were observed on Workdesk. Membership, employees and
|
||||
// every count below are synthetic, not an export of production HR data.
|
||||
const groups = [
|
||||
['business-management', '사업관리', 'office', []],
|
||||
['business-planning', '경영기획', 'office', ['경영기획 셀', '사업관리 셀']],
|
||||
['talent-growth', '인재성장', 'office', ['HR기획 셀', '자산관리 셀']],
|
||||
['integrated-system', '통합시스템 Div', 'div', ['IS 1팀', 'IS 2팀', 'IS 3팀']],
|
||||
['design-planning', '디자인기획', 'office', ['UX 셀', '영상 셀', '편집 셀']],
|
||||
['solution-sales', '솔루션영업', 'office', ['S/W 셀']],
|
||||
];
|
||||
const affiliations = [
|
||||
['hanmac', '(주)한맥기술'], ['saman', '(주)삼안'], ['jangheon', '(주)장헌'],
|
||||
['jangheon-industry', '장헌산업'], ['ptc', 'PTC'], ['halla', '한라산업개발'], ['baron', '바론컨설턴트'],
|
||||
].map(([id, name]) => ({ id, name }));
|
||||
const organizations = [{ id: 'general-planning', parentId: null, name: '총괄기획실', type: 'root', leaderId: 'mock-leader-00', displayOrder: 0 }];
|
||||
// Invented display names only; never sourced from a Workdesk employee capture.
|
||||
const names = [
|
||||
'김솔', '이도윤', '박하준', '정지우', '최예린', '한준서', '오수아',
|
||||
'강민서', '윤시우', '서다은', '배지안', '임서준', '송하린', '조유찬',
|
||||
'신도하', '문예은', '권지호', '안채원', '유시온', '홍서아', '고은우',
|
||||
'남하윤', '백도현', '노유나', '장태오', '양서진', '진서율', '구민재',
|
||||
'전하은', '심이준', '차소율', '우지율', '손예준', '함다온', '허지유',
|
||||
'황서우', '성유빈', '곽지후', '류다인', '하주원', '이봄',
|
||||
];
|
||||
const employees = [{ id: 'mock-leader-00', organizationId: 'general-planning', affiliationId: 'hanmac', name: names[0], position: '경영진', jobTitle: '실장', profileImage: null, workStatus: 'active' }];
|
||||
let person = 1;
|
||||
for (const [index, [id, name, type, cells]] of groups.entries()) {
|
||||
const leaderId = `mock-leader-${String(index + 1).padStart(2, '0')}`;
|
||||
organizations.push({ id, parentId: 'general-planning', name, type, leaderId, displayOrder: index });
|
||||
employees.push({ id: leaderId, organizationId: id, affiliationId: ['hanmac', 'saman', 'baron'][index % 3], name: names[employees.length], position: '수석', jobTitle: type === 'div' ? '디비전장' : '팀장', profileImage: null, workStatus: 'active' });
|
||||
const employeeOrganizations = cells.length ? cells.map((cell, cellIndex) => {
|
||||
const childId = `${id}-${cellIndex + 1}`;
|
||||
organizations.push({ id: childId, parentId: id, name: cell, type: cell.includes('팀') ? 'team' : 'cell', leaderId: null, displayOrder: cellIndex });
|
||||
return childId;
|
||||
}) : [id];
|
||||
for (const childId of employeeOrganizations) for (let member = 0; member < (cells.length ? 3 : 1); member++) {
|
||||
const employeeId = `mock-employee-${String(person).padStart(2, '0')}`;
|
||||
const isTeamLeader = childId.startsWith('integrated-system-') && member === 0;
|
||||
employees.push({ id: employeeId, organizationId: childId, affiliationId: ['hanmac', 'saman', 'ptc', 'baron'][person % 4], name: names[employees.length], position: ['책임', '선임', '연구'][member % 3], jobTitle: isTeamLeader ? '팀장' : null, profileImage: null, workStatus: person === 3 ? 'dispatched' : person === 25 ? 'onLeave' : 'active' });
|
||||
if (isTeamLeader) organizations.find(item => item.id === childId).leaderId = employeeId;
|
||||
person++;
|
||||
}
|
||||
}
|
||||
const fixture = {
|
||||
schemaVersion: '0.1-draft', organizations, employees, affiliations, relations: [],
|
||||
presentation: {
|
||||
title: '총괄기획실 조직도', description: '실제 조직명 · 가상 직원 정보 및 인원 통계 · 운영 인원수가 아닙니다.', hubText: '협업 · 소통 허브',
|
||||
groups: { showEmployees: true, showSummary: true, showDispatched: true, showOnLeave: true, showAffiliations: true },
|
||||
organizationOrder: ['general-planning', ...groups.map(([id]) => id)],
|
||||
},
|
||||
metadata: { asOfDate: '2026-10-12', dataVersion: 'p1-virtual-employees-v1', updateNote: '운영 HR 정보와 무관한 가상 구성' },
|
||||
};
|
||||
await writeFile(new URL('./data-p1.json', import.meta.url), `${JSON.stringify(fixture, null, 2)}\n`);
|
||||
console.log(`P1 fixture: ${organizations.length} organizations / ${employees.length} synthetic employees`);
|
||||
@@ -0,0 +1,618 @@
|
||||
{
|
||||
"schemaVersion": "0.1-draft",
|
||||
"organizations": [
|
||||
{
|
||||
"id": "general-planning",
|
||||
"parentId": null,
|
||||
"name": "총괄기획실",
|
||||
"type": "root",
|
||||
"leaderId": "mock-leader-00",
|
||||
"displayOrder": 0
|
||||
},
|
||||
{
|
||||
"id": "business-management",
|
||||
"parentId": "general-planning",
|
||||
"name": "사업관리",
|
||||
"type": "office",
|
||||
"leaderId": "mock-leader-01",
|
||||
"displayOrder": 0
|
||||
},
|
||||
{
|
||||
"id": "business-planning",
|
||||
"parentId": "general-planning",
|
||||
"name": "경영기획",
|
||||
"type": "office",
|
||||
"leaderId": "mock-leader-02",
|
||||
"displayOrder": 1
|
||||
},
|
||||
{
|
||||
"id": "business-planning-1",
|
||||
"parentId": "business-planning",
|
||||
"name": "경영기획 셀",
|
||||
"type": "cell",
|
||||
"leaderId": null,
|
||||
"displayOrder": 0
|
||||
},
|
||||
{
|
||||
"id": "business-planning-2",
|
||||
"parentId": "business-planning",
|
||||
"name": "사업관리 셀",
|
||||
"type": "cell",
|
||||
"leaderId": null,
|
||||
"displayOrder": 1
|
||||
},
|
||||
{
|
||||
"id": "talent-growth",
|
||||
"parentId": "general-planning",
|
||||
"name": "인재성장",
|
||||
"type": "office",
|
||||
"leaderId": "mock-leader-03",
|
||||
"displayOrder": 2
|
||||
},
|
||||
{
|
||||
"id": "talent-growth-1",
|
||||
"parentId": "talent-growth",
|
||||
"name": "HR기획 셀",
|
||||
"type": "cell",
|
||||
"leaderId": null,
|
||||
"displayOrder": 0
|
||||
},
|
||||
{
|
||||
"id": "talent-growth-2",
|
||||
"parentId": "talent-growth",
|
||||
"name": "자산관리 셀",
|
||||
"type": "cell",
|
||||
"leaderId": null,
|
||||
"displayOrder": 1
|
||||
},
|
||||
{
|
||||
"id": "integrated-system",
|
||||
"parentId": "general-planning",
|
||||
"name": "통합시스템 Div",
|
||||
"type": "div",
|
||||
"leaderId": "mock-leader-04",
|
||||
"displayOrder": 3
|
||||
},
|
||||
{
|
||||
"id": "integrated-system-1",
|
||||
"parentId": "integrated-system",
|
||||
"name": "IS 1팀",
|
||||
"type": "team",
|
||||
"leaderId": "mock-employee-14",
|
||||
"displayOrder": 0
|
||||
},
|
||||
{
|
||||
"id": "integrated-system-2",
|
||||
"parentId": "integrated-system",
|
||||
"name": "IS 2팀",
|
||||
"type": "team",
|
||||
"leaderId": "mock-employee-17",
|
||||
"displayOrder": 1
|
||||
},
|
||||
{
|
||||
"id": "integrated-system-3",
|
||||
"parentId": "integrated-system",
|
||||
"name": "IS 3팀",
|
||||
"type": "team",
|
||||
"leaderId": "mock-employee-20",
|
||||
"displayOrder": 2
|
||||
},
|
||||
{
|
||||
"id": "design-planning",
|
||||
"parentId": "general-planning",
|
||||
"name": "디자인기획",
|
||||
"type": "office",
|
||||
"leaderId": "mock-leader-05",
|
||||
"displayOrder": 4
|
||||
},
|
||||
{
|
||||
"id": "design-planning-1",
|
||||
"parentId": "design-planning",
|
||||
"name": "UX 셀",
|
||||
"type": "cell",
|
||||
"leaderId": null,
|
||||
"displayOrder": 0
|
||||
},
|
||||
{
|
||||
"id": "design-planning-2",
|
||||
"parentId": "design-planning",
|
||||
"name": "영상 셀",
|
||||
"type": "cell",
|
||||
"leaderId": null,
|
||||
"displayOrder": 1
|
||||
},
|
||||
{
|
||||
"id": "design-planning-3",
|
||||
"parentId": "design-planning",
|
||||
"name": "편집 셀",
|
||||
"type": "cell",
|
||||
"leaderId": null,
|
||||
"displayOrder": 2
|
||||
},
|
||||
{
|
||||
"id": "solution-sales",
|
||||
"parentId": "general-planning",
|
||||
"name": "솔루션영업",
|
||||
"type": "office",
|
||||
"leaderId": "mock-leader-06",
|
||||
"displayOrder": 5
|
||||
},
|
||||
{
|
||||
"id": "solution-sales-1",
|
||||
"parentId": "solution-sales",
|
||||
"name": "S/W 셀",
|
||||
"type": "cell",
|
||||
"leaderId": null,
|
||||
"displayOrder": 0
|
||||
}
|
||||
],
|
||||
"employees": [
|
||||
{
|
||||
"id": "mock-leader-00",
|
||||
"organizationId": "general-planning",
|
||||
"affiliationId": "hanmac",
|
||||
"name": "김솔",
|
||||
"position": "경영진",
|
||||
"jobTitle": "실장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-leader-01",
|
||||
"organizationId": "business-management",
|
||||
"affiliationId": "hanmac",
|
||||
"name": "이도윤",
|
||||
"position": "수석",
|
||||
"jobTitle": "팀장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-01",
|
||||
"organizationId": "business-management",
|
||||
"affiliationId": "saman",
|
||||
"name": "박하준",
|
||||
"position": "책임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-leader-02",
|
||||
"organizationId": "business-planning",
|
||||
"affiliationId": "saman",
|
||||
"name": "정지우",
|
||||
"position": "수석",
|
||||
"jobTitle": "팀장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-02",
|
||||
"organizationId": "business-planning-1",
|
||||
"affiliationId": "ptc",
|
||||
"name": "최예린",
|
||||
"position": "책임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-03",
|
||||
"organizationId": "business-planning-1",
|
||||
"affiliationId": "baron",
|
||||
"name": "한준서",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "dispatched"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-04",
|
||||
"organizationId": "business-planning-1",
|
||||
"affiliationId": "hanmac",
|
||||
"name": "오수아",
|
||||
"position": "연구",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-05",
|
||||
"organizationId": "business-planning-2",
|
||||
"affiliationId": "saman",
|
||||
"name": "강민서",
|
||||
"position": "책임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-06",
|
||||
"organizationId": "business-planning-2",
|
||||
"affiliationId": "ptc",
|
||||
"name": "윤시우",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-07",
|
||||
"organizationId": "business-planning-2",
|
||||
"affiliationId": "baron",
|
||||
"name": "서다은",
|
||||
"position": "연구",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-leader-03",
|
||||
"organizationId": "talent-growth",
|
||||
"affiliationId": "baron",
|
||||
"name": "배지안",
|
||||
"position": "수석",
|
||||
"jobTitle": "팀장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-08",
|
||||
"organizationId": "talent-growth-1",
|
||||
"affiliationId": "hanmac",
|
||||
"name": "임서준",
|
||||
"position": "책임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-09",
|
||||
"organizationId": "talent-growth-1",
|
||||
"affiliationId": "saman",
|
||||
"name": "송하린",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-10",
|
||||
"organizationId": "talent-growth-1",
|
||||
"affiliationId": "ptc",
|
||||
"name": "조유찬",
|
||||
"position": "연구",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-11",
|
||||
"organizationId": "talent-growth-2",
|
||||
"affiliationId": "baron",
|
||||
"name": "신도하",
|
||||
"position": "책임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-12",
|
||||
"organizationId": "talent-growth-2",
|
||||
"affiliationId": "hanmac",
|
||||
"name": "문예은",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-13",
|
||||
"organizationId": "talent-growth-2",
|
||||
"affiliationId": "saman",
|
||||
"name": "권지호",
|
||||
"position": "연구",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-leader-04",
|
||||
"organizationId": "integrated-system",
|
||||
"affiliationId": "hanmac",
|
||||
"name": "안채원",
|
||||
"position": "수석",
|
||||
"jobTitle": "디비전장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-14",
|
||||
"organizationId": "integrated-system-1",
|
||||
"affiliationId": "ptc",
|
||||
"name": "유시온",
|
||||
"position": "책임",
|
||||
"jobTitle": "팀장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-15",
|
||||
"organizationId": "integrated-system-1",
|
||||
"affiliationId": "baron",
|
||||
"name": "홍서아",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-16",
|
||||
"organizationId": "integrated-system-1",
|
||||
"affiliationId": "hanmac",
|
||||
"name": "고은우",
|
||||
"position": "연구",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-17",
|
||||
"organizationId": "integrated-system-2",
|
||||
"affiliationId": "saman",
|
||||
"name": "남하윤",
|
||||
"position": "책임",
|
||||
"jobTitle": "팀장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-18",
|
||||
"organizationId": "integrated-system-2",
|
||||
"affiliationId": "ptc",
|
||||
"name": "백도현",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-19",
|
||||
"organizationId": "integrated-system-2",
|
||||
"affiliationId": "baron",
|
||||
"name": "노유나",
|
||||
"position": "연구",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-20",
|
||||
"organizationId": "integrated-system-3",
|
||||
"affiliationId": "hanmac",
|
||||
"name": "장태오",
|
||||
"position": "책임",
|
||||
"jobTitle": "팀장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-21",
|
||||
"organizationId": "integrated-system-3",
|
||||
"affiliationId": "saman",
|
||||
"name": "양서진",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-22",
|
||||
"organizationId": "integrated-system-3",
|
||||
"affiliationId": "ptc",
|
||||
"name": "진서율",
|
||||
"position": "연구",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-leader-05",
|
||||
"organizationId": "design-planning",
|
||||
"affiliationId": "saman",
|
||||
"name": "구민재",
|
||||
"position": "수석",
|
||||
"jobTitle": "팀장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-23",
|
||||
"organizationId": "design-planning-1",
|
||||
"affiliationId": "baron",
|
||||
"name": "전하은",
|
||||
"position": "책임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-24",
|
||||
"organizationId": "design-planning-1",
|
||||
"affiliationId": "hanmac",
|
||||
"name": "심이준",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-25",
|
||||
"organizationId": "design-planning-1",
|
||||
"affiliationId": "saman",
|
||||
"name": "차소율",
|
||||
"position": "연구",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "onLeave"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-26",
|
||||
"organizationId": "design-planning-2",
|
||||
"affiliationId": "ptc",
|
||||
"name": "우지율",
|
||||
"position": "책임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-27",
|
||||
"organizationId": "design-planning-2",
|
||||
"affiliationId": "baron",
|
||||
"name": "손예준",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-28",
|
||||
"organizationId": "design-planning-2",
|
||||
"affiliationId": "hanmac",
|
||||
"name": "함다온",
|
||||
"position": "연구",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-29",
|
||||
"organizationId": "design-planning-3",
|
||||
"affiliationId": "saman",
|
||||
"name": "허지유",
|
||||
"position": "책임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-30",
|
||||
"organizationId": "design-planning-3",
|
||||
"affiliationId": "ptc",
|
||||
"name": "황서우",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-31",
|
||||
"organizationId": "design-planning-3",
|
||||
"affiliationId": "baron",
|
||||
"name": "성유빈",
|
||||
"position": "연구",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-leader-06",
|
||||
"organizationId": "solution-sales",
|
||||
"affiliationId": "baron",
|
||||
"name": "곽지후",
|
||||
"position": "수석",
|
||||
"jobTitle": "팀장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-32",
|
||||
"organizationId": "solution-sales-1",
|
||||
"affiliationId": "hanmac",
|
||||
"name": "류다인",
|
||||
"position": "책임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-33",
|
||||
"organizationId": "solution-sales-1",
|
||||
"affiliationId": "saman",
|
||||
"name": "하주원",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "mock-employee-34",
|
||||
"organizationId": "solution-sales-1",
|
||||
"affiliationId": "ptc",
|
||||
"name": "이봄",
|
||||
"position": "연구",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
}
|
||||
],
|
||||
"affiliations": [
|
||||
{
|
||||
"id": "hanmac",
|
||||
"name": "(주)한맥기술"
|
||||
},
|
||||
{
|
||||
"id": "saman",
|
||||
"name": "(주)삼안"
|
||||
},
|
||||
{
|
||||
"id": "jangheon",
|
||||
"name": "(주)장헌"
|
||||
},
|
||||
{
|
||||
"id": "jangheon-industry",
|
||||
"name": "장헌산업"
|
||||
},
|
||||
{
|
||||
"id": "ptc",
|
||||
"name": "PTC"
|
||||
},
|
||||
{
|
||||
"id": "halla",
|
||||
"name": "한라산업개발"
|
||||
},
|
||||
{
|
||||
"id": "baron",
|
||||
"name": "바론컨설턴트"
|
||||
}
|
||||
],
|
||||
"relations": [],
|
||||
"presentation": {
|
||||
"title": "총괄기획실 조직도",
|
||||
"description": "실제 조직명 · 가상 직원 정보 및 인원 통계 · 운영 인원수가 아닙니다.",
|
||||
"hubText": "협업 · 소통 허브",
|
||||
"groups": {
|
||||
"showEmployees": true,
|
||||
"showSummary": true,
|
||||
"showDispatched": true,
|
||||
"showOnLeave": true,
|
||||
"showAffiliations": true
|
||||
},
|
||||
"organizationOrder": [
|
||||
"general-planning",
|
||||
"business-management",
|
||||
"business-planning",
|
||||
"talent-growth",
|
||||
"integrated-system",
|
||||
"design-planning",
|
||||
"solution-sales"
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"asOfDate": "2026-10-12",
|
||||
"dataVersion": "p1-virtual-employees-v1",
|
||||
"updateNote": "운영 HR 정보와 무관한 가상 구성"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"organizations": [
|
||||
{
|
||||
"id": "org-root",
|
||||
"parentId": null,
|
||||
"name": "가상 사업본부",
|
||||
"type": "root",
|
||||
"leaderId": "emp-a",
|
||||
"displayOrder": 0
|
||||
},
|
||||
{
|
||||
"id": "team-a",
|
||||
"parentId": "org-root",
|
||||
"name": "가상 기획팀",
|
||||
"type": "team",
|
||||
"leaderId": "emp-b",
|
||||
"displayOrder": 1
|
||||
},
|
||||
{
|
||||
"id": "team-b",
|
||||
"parentId": "org-root",
|
||||
"name": "가상 운영팀",
|
||||
"type": "team",
|
||||
"leaderId": null,
|
||||
"displayOrder": 2
|
||||
}
|
||||
],
|
||||
"employees": [
|
||||
{
|
||||
"id": "emp-a",
|
||||
"organizationId": "org-root",
|
||||
"affiliationId": "aff-a",
|
||||
"name": "가상직원 가",
|
||||
"position": "대표",
|
||||
"jobTitle": "본부장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "emp-b",
|
||||
"organizationId": "team-a",
|
||||
"affiliationId": "aff-a",
|
||||
"name": "가상직원 나",
|
||||
"position": "책임",
|
||||
"jobTitle": "팀장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "emp-c",
|
||||
"organizationId": "team-a",
|
||||
"affiliationId": "aff-b",
|
||||
"name": "가상직원 다",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "dispatched"
|
||||
},
|
||||
{
|
||||
"id": "emp-d",
|
||||
"organizationId": "team-b",
|
||||
"affiliationId": "aff-b",
|
||||
"name": "가상직원 라",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "onLeave"
|
||||
}
|
||||
],
|
||||
"affiliations": [
|
||||
{
|
||||
"id": "aff-a",
|
||||
"name": "가상 관계사 A"
|
||||
},
|
||||
{
|
||||
"id": "aff-b",
|
||||
"name": "가상 관계사 B"
|
||||
}
|
||||
],
|
||||
"relations": [
|
||||
{
|
||||
"id": "rel-a",
|
||||
"sourceOrganizationId": "team-a",
|
||||
"targetOrganizationId": "team-b",
|
||||
"type": "cooperation"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"title": "가상 조직도",
|
||||
"description": "가상 데이터 · 원본 집계와 업무상 총원은 구분됩니다.",
|
||||
"hubText": "협업 중심",
|
||||
"groups": {
|
||||
"showEmployees": true,
|
||||
"showSummary": true,
|
||||
"showDispatched": true,
|
||||
"showOnLeave": true,
|
||||
"showAffiliations": true
|
||||
},
|
||||
"organizationOrder": [
|
||||
"org-root",
|
||||
"team-a",
|
||||
"team-b"
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"schemaVersion": "0.1-draft",
|
||||
"asOfDate": "2026-10-12",
|
||||
"dataVersion": "mock-1",
|
||||
"updatedAt": null,
|
||||
"updateNote": "초안 예시"
|
||||
},
|
||||
"schemaVersion": "0.1-draft"
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"organizations": [
|
||||
{
|
||||
"id": "org-root",
|
||||
"parentId": null,
|
||||
"name": "가상 사업본부",
|
||||
"type": "root",
|
||||
"leaderId": "emp-a",
|
||||
"displayOrder": 0
|
||||
},
|
||||
{
|
||||
"id": "team-a",
|
||||
"parentId": "org-root",
|
||||
"name": "가상 전략기획팀",
|
||||
"type": "team",
|
||||
"leaderId": "emp-b",
|
||||
"displayOrder": 1
|
||||
},
|
||||
{
|
||||
"id": "team-b",
|
||||
"parentId": "org-root",
|
||||
"name": "가상 운영팀",
|
||||
"type": "team",
|
||||
"leaderId": null,
|
||||
"displayOrder": 2
|
||||
}
|
||||
],
|
||||
"employees": [
|
||||
{
|
||||
"id": "emp-a",
|
||||
"organizationId": "org-root",
|
||||
"affiliationId": "aff-a",
|
||||
"name": "가상직원 가",
|
||||
"position": "대표",
|
||||
"jobTitle": "본부장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "emp-b",
|
||||
"organizationId": "team-a",
|
||||
"affiliationId": "aff-a",
|
||||
"name": "가상직원 나",
|
||||
"position": "책임",
|
||||
"jobTitle": "팀장",
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
},
|
||||
{
|
||||
"id": "emp-c",
|
||||
"organizationId": "team-b",
|
||||
"affiliationId": "aff-b",
|
||||
"name": "가상직원 다",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "dispatched"
|
||||
},
|
||||
{
|
||||
"id": "emp-d",
|
||||
"organizationId": "team-b",
|
||||
"affiliationId": "aff-b",
|
||||
"name": "가상직원 라",
|
||||
"position": "선임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "onLeave"
|
||||
},
|
||||
{
|
||||
"id": "emp-e",
|
||||
"organizationId": "team-b",
|
||||
"affiliationId": "aff-a",
|
||||
"name": "가상직원 마",
|
||||
"position": "책임",
|
||||
"jobTitle": null,
|
||||
"profileImage": null,
|
||||
"workStatus": "active"
|
||||
}
|
||||
],
|
||||
"affiliations": [
|
||||
{
|
||||
"id": "aff-a",
|
||||
"name": "가상 관계사 A"
|
||||
},
|
||||
{
|
||||
"id": "aff-b",
|
||||
"name": "가상 관계사 B"
|
||||
}
|
||||
],
|
||||
"relations": [
|
||||
{
|
||||
"id": "rel-a",
|
||||
"sourceOrganizationId": "team-a",
|
||||
"targetOrganizationId": "team-b",
|
||||
"type": "cooperation"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"title": "가상 조직도",
|
||||
"description": "가상 데이터 · 원본 집계와 업무상 총원은 구분됩니다.",
|
||||
"hubText": "협업 중심",
|
||||
"groups": {
|
||||
"showEmployees": true,
|
||||
"showSummary": true,
|
||||
"showDispatched": true,
|
||||
"showOnLeave": true,
|
||||
"showAffiliations": true
|
||||
},
|
||||
"organizationOrder": [
|
||||
"org-root",
|
||||
"team-a",
|
||||
"team-b"
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"schemaVersion": "0.1-draft",
|
||||
"asOfDate": "2026-10-12",
|
||||
"dataVersion": "mock-2",
|
||||
"updatedAt": null,
|
||||
"updateNote": "초안 예시"
|
||||
},
|
||||
"schemaVersion": "0.1-draft"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
body { margin: 0; font-family: var(--hm-font-family-base); color: var(--hm-sem-fg-primary); background: var(--hm-sem-bg-content); }
|
||||
main { padding: var(--hm-card-padding); }
|
||||
h1 { margin: 0; font-size: var(--hm-card-title-font-size); font-weight: var(--hm-card-title-font-weight); }
|
||||
main > p { color: var(--hm-sem-fg-secondary); }
|
||||
.demo-controls { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.demo-controls button { font: inherit; color: var(--hm-sem-fg-primary); background: var(--hm-sem-bg-surface); border: 1px solid var(--hm-sem-stroke-default); border-radius: var(--hm-radius-control-md); padding: 8px 12px; cursor: pointer; }
|
||||
.demo-controls button:focus-visible { outline: 2px solid var(--hm-sem-stroke-input-focus); outline-offset: 2px; }
|
||||
#chart-host { min-width: 0; }
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HM OrgChart · Vanilla JSON 예제</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="./assets/hm-theme.css"><link rel="stylesheet" href="./demo.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>OrgChart Core · JSON 예제</h1>
|
||||
<p>가상 조직·직원 데이터입니다. 표시된 원본 직원 수는 업무상 재직 총원과 구분됩니다.</p>
|
||||
<div class="demo-controls" aria-label="Core 실행 예제">
|
||||
<button id="reload" type="button">JSON 재로드</button>
|
||||
<button id="update" type="button">직원 이동·추가 반영</button>
|
||||
<button id="destroy" type="button">인스턴스 정리</button>
|
||||
<button id="recreate" type="button">새 인스턴스 생성</button>
|
||||
</div>
|
||||
<p id="status" role="status" aria-live="polite">준비 중</p>
|
||||
<div id="chart-host"></div>
|
||||
</main>
|
||||
<script type="module">
|
||||
import * as HMOrgChart from './assets/index.js';
|
||||
import { startDemo } from './app.js';
|
||||
startDemo(HMOrgChart);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
export async function startP1(api, inlineFixture) {
|
||||
const data = inlineFixture ?? await new api.JsonOrgChartDataSource('./data-p1.json').load();
|
||||
const stylesheetUrls = {
|
||||
engine: new URL('./assets/engine.css', location.href).href,
|
||||
core: new URL('./assets/style.css', location.href).href,
|
||||
};
|
||||
// ?mode=readOnly shows the view-only Workdesk composition; the default stays interactive.
|
||||
const displayMode = new URLSearchParams(location.search).get('mode') === 'readOnly' ? 'readOnly' : 'interactive';
|
||||
document.documentElement.dataset.orgchartMode = displayMode;
|
||||
const chart = api.createOrgChart({ container: document.getElementById('chart-host'), dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrls, displayMode });
|
||||
// Development inspection seam: public Core API only. No engine internals.
|
||||
window.OrgChartP1 = { api, data, chart, stylesheetUrls, setData: next => chart.update(next) };
|
||||
window.addEventListener('pagehide', () => chart.destroy(), { once: true });
|
||||
await chart.mount();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HM OrgChart P1 · 직접 열기</title><link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="./p1.css">
|
||||
</head>
|
||||
<body>
|
||||
<main><div id="chart-host"></div></main>
|
||||
<script src="./assets/orgchart.browser.js"></script><script src="./p1-fixture.js"></script>
|
||||
<script src="./p1-app-classic.js"></script><script>startP1(window.HMOrgChart, window.OrgChartP1Fixture);</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
/* Reuse the existing local variable font; only OrgChart selects this family. */
|
||||
@font-face {
|
||||
font-family: 'Noto Sans KR';
|
||||
src: url('./assets/fonts/NotoSansKR-variable.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
body { margin: 0; background: #ffffff; }
|
||||
main { padding: 16px; }
|
||||
#chart-host { min-width: 0; }
|
||||
html[data-orgchart-mode="readOnly"] body { background: #F5F8FB; }
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HM OrgChart P1 · Full Composition</title><link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="./p1.css">
|
||||
</head>
|
||||
<body>
|
||||
<main><div id="chart-host"></div></main>
|
||||
<script type="module">
|
||||
import * as api from './assets/index.js';
|
||||
import { startP1 } from './p1-app.js';
|
||||
startP1(api);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
const example = fileURLToPath(new URL('./', import.meta.url));
|
||||
const root = path.resolve(example, '../..');
|
||||
const output = path.join(example, 'dist');
|
||||
await mkdir(path.join(output, 'assets'), { recursive: true });
|
||||
// The host's theme is generated from the existing public CSS entry; no maintained theme fork.
|
||||
async function flatten(file, seen = new Set()) {
|
||||
const full = path.resolve(file);
|
||||
if (seen.has(full)) throw new Error('Theme CSS import cycle');
|
||||
const stack = new Set(seen).add(full);
|
||||
const source = await readFile(full, 'utf8');
|
||||
const matches = [...source.matchAll(/@import\s+["']([^"']+)["'];/g)];
|
||||
let result = '', cursor = 0;
|
||||
for (const match of matches) {
|
||||
result += source.slice(cursor, match.index) + await flatten(path.resolve(path.dirname(full), match[1]), stack);
|
||||
cursor = match.index + match[0].length;
|
||||
}
|
||||
return result + source.slice(cursor);
|
||||
}
|
||||
const theme = (await flatten(path.join(root, 'packages/ui/src/theme.css')))
|
||||
.replace('/fonts/NotoSansKR-variable.ttf', './fonts/NotoSansKR-variable.ttf');
|
||||
await writeFile(path.join(output, 'assets/hm-theme.css'), theme);
|
||||
await cp(path.join(root, 'public/fonts'), path.join(output, 'assets/fonts'), { recursive: true });
|
||||
for (const file of ['index.js', 'orgchart.browser.js', 'style.css', 'engine.css'])
|
||||
await cp(path.join(root, 'packages/org-chart/dist', file), path.join(output, 'assets', file));
|
||||
await cp(path.join(root, 'packages/org-chart/dist/licenses'), path.join(output, 'assets/licenses'), { recursive: true });
|
||||
for (const file of ['index.html', 'classic.html', 'app.js', 'demo.css', 'data-v1.json', 'data-v2.json', 'data-p1.json', 'p1.html', 'p1-classic.html', 'p1-app.js', 'p1.css'])
|
||||
await cp(path.join(example, file), path.join(output, file));
|
||||
const initial = JSON.parse(await readFile(path.join(example, 'data-v1.json'), 'utf8'));
|
||||
const changed = JSON.parse(await readFile(path.join(example, 'data-v2.json'), 'utf8'));
|
||||
await writeFile(path.join(output, 'fixtures.js'), `window.OrgChartFixtures = ${JSON.stringify({initial, changed})};\n`);
|
||||
const app = await readFile(path.join(example, 'app.js'), 'utf8');
|
||||
await writeFile(path.join(output, 'app-classic.js'), app.replace('export function startDemo', 'function startDemo'));
|
||||
const p1 = JSON.parse(await readFile(path.join(example, 'data-p1.json'), 'utf8'));
|
||||
await writeFile(path.join(output, 'p1-fixture.js'), `window.OrgChartP1Fixture = ${JSON.stringify(p1)};\n`);
|
||||
const p1App = await readFile(path.join(example, 'p1-app.js'), 'utf8');
|
||||
await writeFile(path.join(output, 'p1-app-classic.js'), p1App.replace('export async function startP1', 'async function startP1'));
|
||||
console.log(`OrgChart HTML example: ${output}`);
|
||||
@@ -0,0 +1,18 @@
|
||||
import http from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
const base = fileURLToPath(new URL('./dist/', import.meta.url));
|
||||
const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.css': 'text/css; charset=utf-8', '.ttf': 'font/ttf' };
|
||||
const port = Number(process.env.ORGCHART_PORT || 4178);
|
||||
http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const file = path.resolve(base, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
|
||||
const relative = path.relative(base, file);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; }
|
||||
const data = await readFile(file);
|
||||
response.setHeader('Content-Type', mime[path.extname(file)] || 'application/octet-stream');
|
||||
response.setHeader('Cache-Control', 'no-store'); response.end(data);
|
||||
} catch { response.writeHead(404).end(); }
|
||||
}).listen(port, '127.0.0.1', () => console.log(`OrgChart example: http://127.0.0.1:${port}`));
|
||||
@@ -0,0 +1,204 @@
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// Stage 2 only: typography, data placement, overflow and accessible tooltip.
|
||||
// Do not repeat Stage 1's gradient/data/Summary/engine suites.
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const output = path.join(root, 'test-results/org-chart-card-content');
|
||||
await mkdir(output, { recursive: true });
|
||||
const { chromium } = await import(process.env.ORGCHART_PLAYWRIGHT_MODULE ? pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href : 'playwright');
|
||||
const results = [], errors = [], measurements = [], servers = [];
|
||||
const check = (value, name, detail) => results.push({ name, status: value ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf' };
|
||||
async function serve(directory) {
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://127.0.0.1');
|
||||
if (url.pathname === '/profile-fixture.svg') {
|
||||
response.setHeader('Content-Type', 'image/svg+xml');
|
||||
return response.end('<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><circle cx="20" cy="20" r="20" fill="#45639f"/></svg>');
|
||||
}
|
||||
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
|
||||
const relative = path.relative(directory, file);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
|
||||
response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream');
|
||||
response.end(await readFile(file));
|
||||
} catch { response.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server);
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
async function ready(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
|
||||
return shadow?.querySelector('[data-card-asset] .hm-orgchart-person-name[data-name-overflow]')
|
||||
&& [...shadow.querySelectorAll('link')].every(link => !!link.sheet);
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||||
await Promise.all([...shadow.querySelectorAll('img')].map(img => img.decode().catch(() => {})));
|
||||
});
|
||||
}
|
||||
async function measure(page) {
|
||||
return page.evaluate(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||||
const rect = element => { const b = element.getBoundingClientRect(); return { left: b.left, right: b.right, top: b.top, bottom: b.bottom, width: b.width, height: b.height }; };
|
||||
const typography = element => {
|
||||
const style = getComputedStyle(element);
|
||||
return Object.fromEntries(['color', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'margin', 'padding', 'whiteSpace', 'textOverflow', 'fontFamily'].map(key => [key, style[key]]));
|
||||
};
|
||||
const cards = [...shadow.querySelectorAll('[data-card-asset]')].map(card => {
|
||||
const role = card.querySelector('.hm-orgchart-person-role'), name = card.querySelector('.hm-orgchart-person-name');
|
||||
const avatar = card.querySelector('.hm-orgchart-avatar'), logo = card.querySelector('.hm-orgchart-affiliation-mark');
|
||||
const bounds = rect(card), roleBounds = rect(role), nameBounds = rect(name), issues = [];
|
||||
const overlap = (a, b) => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1;
|
||||
const contained = b => b.left >= bounds.left - 1 && b.right <= bounds.right + 1 && b.top >= bounds.top - 1 && b.bottom <= bounds.bottom + 1;
|
||||
for (const [label, element] of [['role', role], ['name', name], ['avatar', avatar], ['logo', logo]]) if (!contained(rect(element))) issues.push(`${label} outside card`);
|
||||
for (const element of [role, name]) {
|
||||
// A clipped name's DOM Range intentionally retains the full text width;
|
||||
// visible text is intersected with its clipping box for collision checks.
|
||||
const range = document.createRange(); range.selectNodeContents(element);
|
||||
for (const r of range.getClientRects()) {
|
||||
const box = rect(element);
|
||||
const visible = { left: Math.max(r.left, box.left), right: Math.min(r.right, box.right), top: Math.max(r.top, box.top), bottom: Math.min(r.bottom, box.bottom) };
|
||||
if (overlap(visible, rect(avatar))) issues.push(`${element.className}: avatar overlap`);
|
||||
if (overlap(visible, rect(logo))) issues.push(`${element.className}: logo overlap`);
|
||||
}
|
||||
}
|
||||
const badge = card.querySelector('.hm-orgchart-job-title');
|
||||
if (badge) {
|
||||
if (!contained(rect(badge))) issues.push('badge outside card');
|
||||
if (overlap(rect(badge), rect(avatar)) || overlap(rect(badge), nameBounds)) issues.push('badge overlap');
|
||||
}
|
||||
return { id: card.dataset.employeeId, size: card.dataset.cardAssetSize, name: name.textContent, role: typography(role), person: typography(name),
|
||||
width: bounds.width, height: bounds.height, avatarWidth: rect(avatar).width, avatarHeight: rect(avatar).height,
|
||||
textGap: nameBounds.top - roleBounds.bottom, cssGap: getComputedStyle(card.querySelector('.hm-orgchart-person-text')).gap,
|
||||
overflow: name.dataset.nameOverflow, focusable: name.getAttribute('tabindex'), description: !!name.getAttribute('aria-describedby'),
|
||||
nameWidth: name.clientWidth, nameScrollWidth: name.scrollWidth, issues,
|
||||
imageLoaded: !!avatar.querySelector('img')?.naturalWidth,
|
||||
logoTransparent: getComputedStyle(logo.querySelector('img')).backgroundColor === 'rgba(0, 0, 0, 0)',
|
||||
fallbackHidden: getComputedStyle(logo.querySelector('.hm-orgchart-affiliation-fallback')).visibility === 'hidden' };
|
||||
});
|
||||
const viewport = shadow.querySelector('.hm-orgchart-viewport');
|
||||
return { cards, pageOverflow: document.documentElement.scrollWidth > innerWidth + 1,
|
||||
extents: { document: [document.documentElement.scrollWidth, document.documentElement.scrollHeight, document.documentElement.clientWidth, document.documentElement.clientHeight],
|
||||
viewport: viewport ? [viewport.scrollWidth, viewport.scrollHeight, viewport.clientWidth, viewport.clientHeight] : null },
|
||||
orphanTooltips: [...shadow.querySelectorAll('[role=tooltip]')].filter(tip => !shadow.querySelector(`[aria-describedby="${tip.id}"]`)).length };
|
||||
});
|
||||
}
|
||||
function verifyTypography(value, label) {
|
||||
for (const size of ['representative', 'leader', 'employee']) {
|
||||
const cards = value.cards.filter(card => card.size === size), small = size === 'employee';
|
||||
check(cards.length > 0 && cards.every(card => card.role.color === (size === 'representative' ? 'rgb(34, 67, 135)' : 'rgb(121, 121, 121)')
|
||||
&& card.role.fontSize === (small ? '15px' : '20px') && card.role.fontWeight === '400' && card.role.lineHeight === (small ? '18px' : '22px')
|
||||
&& card.role.letterSpacing === (small ? '-0.6px' : '-0.8px')), `${label}/${size}: position exact computed typography`);
|
||||
check(cards.every(card => card.person.color === 'rgb(7, 29, 58)' && card.person.fontSize === (small ? '20px' : '24px') && card.person.fontWeight === '600'
|
||||
&& card.person.lineHeight === (small ? '24px' : '30px') && card.person.letterSpacing === (size === 'leader' ? '-0.96px' : '-0.8px')), `${label}/${size}: name exact computed typography`);
|
||||
check(cards.every(card => card.cssGap === '0px' && card.textGap === 0 && card.role.margin === '0px' && card.role.padding === '0px'
|
||||
&& card.person.margin === '0px' && card.person.padding === '0px'), `${label}/${size}: zero gap/margins/padding between two text rows`);
|
||||
check(cards.every(card => card.width === (small ? 108 : 146) && card.height === (size === 'representative' ? 74 : small ? 52 : 68)
|
||||
&& card.avatarWidth === (size === 'representative' ? 36 : small ? 24 : 32) && card.avatarHeight === card.avatarWidth), `${label}/${size}: original card/profile dimensions retained`);
|
||||
}
|
||||
check(value.cards.every(card => card.issues.length === 0), `${label}: content collisions/bounds`, value.cards.filter(card => card.issues.length));
|
||||
check(value.cards.every(card => card.logoTransparent && card.fallbackHidden), `${label}: transparent original logos/fallback retained`);
|
||||
check(!value.pageOverflow && value.orphanTooltips === 0, `${label}: no new page overflow/orphan tooltip`);
|
||||
}
|
||||
async function tooltipChecks(page, label) {
|
||||
const names = page.locator('[data-card-asset] .hm-orgchart-person-name[data-name-overflow=true]');
|
||||
for (const size of ['representative', 'leader', 'employee']) {
|
||||
const name = page.locator(`[data-card-asset-size="${size}"] .hm-orgchart-person-name[data-name-overflow=true]`).first();
|
||||
await name.scrollIntoViewIfNeeded();
|
||||
await name.hover();
|
||||
const description = await name.getAttribute('aria-describedby'), tip = page.locator(`#${description}`);
|
||||
check(await tip.isVisible() && await tip.innerText() === await name.innerText(), `${label}/${size}: hover shows complete original name`);
|
||||
await tip.hover();
|
||||
check(await tip.isVisible(), `${label}/${size}: tooltip hoverable`);
|
||||
await page.mouse.move(0, 0);
|
||||
await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot.querySelectorAll('[role=tooltip]')].every(tip => tip.hidden));
|
||||
// Tab through the real keyboard order from this explicit preceding control.
|
||||
await name.evaluate(element => { const previous = document.createElement('button'); previous.textContent = 'keyboard test start'; element.before(previous); previous.focus(); });
|
||||
await page.keyboard.press('Tab');
|
||||
check(await name.evaluate(element => element.getRootNode().activeElement === element) && await tip.isVisible(), `${label}/${size}: Tab focus reveals full name`);
|
||||
check((await name.ariaSnapshot()).includes(await name.innerText()) && await name.getAttribute('aria-describedby') === await tip.getAttribute('id'), `${label}/${size}: full text retained in Chromium accessibility representation/description`);
|
||||
await page.keyboard.press('Escape');
|
||||
check(!(await tip.isVisible()) && await name.evaluate(element => element.getRootNode().activeElement === element), `${label}/${size}: Escape dismisses, focus retained`);
|
||||
const tipBox = await tip.evaluate(element => { element.hidden = false; const b = element.getBoundingClientRect(); element.hidden = true; return { left: b.left, right: b.right, top: b.top, bottom: b.bottom }; });
|
||||
const screen = page.viewportSize();
|
||||
check(tipBox.left >= 0 && tipBox.right <= screen.width + 1 && tipBox.top >= 0 && tipBox.bottom <= screen.height + 1, `${label}/${size}: tooltip inside browser viewport`);
|
||||
await name.evaluate(element => element.previousElementSibling.remove());
|
||||
}
|
||||
check(await names.count() === 3, `${label}: only the three explicit long-name cases are focusable`);
|
||||
}
|
||||
let browser;
|
||||
try {
|
||||
const origins = await Promise.all(['storybook-static', 'examples/org-chart/dist', 'examples/org-chart-react/dist'].map(directory => serve(path.join(root, directory))));
|
||||
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce' });
|
||||
const routes = [['department-colors', `${origins[0]}/iframe.html?id=orgchart--department-colors&viewMode=story`],
|
||||
['full-composition', `${origins[0]}/iframe.html?id=orgchart--full-composition&viewMode=story`], ['html', `${origins[1]}/p1.html`], ['react', `${origins[2]}/p1.html`]];
|
||||
for (const [label, url] of routes) {
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => errors.push({ label, message: error.message }));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push({ label, message: message.text() }); });
|
||||
await page.goto(url); await ready(page);
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
await page.setViewportSize({ width, height: 1000 }); await ready(page);
|
||||
const value = await measure(page); measurements.push({ label, width, ...value }); verifyTypography(value, `${label}/${width}`);
|
||||
check(value.cards.every(card => /^[가-힣]{2,3}$/.test(card.name) && card.overflow === 'false' && card.focusable === null && card.person.textOverflow === 'clip'), `${label}/${width}: natural short names remain complete and outside tab order`);
|
||||
}
|
||||
if (label === 'department-colors') {
|
||||
await page.locator('.hm-orgchart-representative-preview').screenshot({ path: path.join(output, 'representative-normal.png') });
|
||||
for (const [index, department] of [[0, 'business-management'], [1, 'business-planning'], [3, 'integrated-system']]) await page.locator('.hm-orgchart-preview-grid').nth(index).screenshot({ path: path.join(output, `${department}-normal.png`) });
|
||||
await page.getByRole('button', { name: '긴 이름 검증 시작' }).click(); await ready(page);
|
||||
for (const width of [390, 1440]) {
|
||||
await page.setViewportSize({ width, height: 1000 }); await ready(page);
|
||||
const value = await measure(page); measurements.push({ label: 'storybook-long', width, ...value }); verifyTypography(value, `storybook-long/${width}`);
|
||||
check(value.cards.filter(card => card.overflow === 'true').length === 3 && value.cards.filter(card => card.overflow === 'true').every(card => card.person.textOverflow === 'ellipsis' && card.focusable === '0' && card.description), `storybook-long/${width}: ellipsis and focus only on overflowing names`);
|
||||
await tooltipChecks(page, `storybook-long/${width}`);
|
||||
}
|
||||
await page.locator('.hm-orgchart-representative-preview').scrollIntoViewIfNeeded();
|
||||
await page.locator('[data-card-asset-size=representative] .hm-orgchart-person-name').focus();
|
||||
await page.screenshot({ path: path.join(output, 'representative-long-focus.png') });
|
||||
await page.getByRole('button', { name: '긴 이름 검증 종료' }).click(); await ready(page);
|
||||
check((await measure(page)).orphanTooltips === 0 && await page.locator('[role=tooltip]').count() === 0, 'Story long -> normal disposes tooltip nodes');
|
||||
}
|
||||
if (label === 'full-composition') {
|
||||
await page.locator('.node[data-organization-id=business-management]').scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(output, 'composition-1440.png') });
|
||||
}
|
||||
if (label === 'html') {
|
||||
await page.evaluate(async () => {
|
||||
const next = structuredClone(window.OrgChartP1.data);
|
||||
for (const id of ['mock-leader-00', 'mock-leader-01', 'mock-employee-01']) {
|
||||
const person = next.employees.find(item => item.id === id);
|
||||
person.name = '아주긴가상이름표시검증사례'; person.profileImage = `${location.origin}/profile-fixture.svg`;
|
||||
}
|
||||
await window.OrgChartP1.setData(next);
|
||||
}); await ready(page);
|
||||
const value = await measure(page); verifyTypography(value, 'html-long-images');
|
||||
check(value.cards.filter(card => card.overflow === 'true').every(card => card.imageLoaded), 'long names keep original-size profile images');
|
||||
await tooltipChecks(page, 'html-long-images');
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
const html = measurements.find(item => item.label === 'html' && item.width === width);
|
||||
const react = measurements.find(item => item.label === 'react' && item.width === width);
|
||||
check(JSON.stringify(html.cards) === JSON.stringify(react.cards), `HTML/React computed typography/content parity/${width}`);
|
||||
}
|
||||
const before = JSON.parse(await readFile(path.join(output, 'data-p1-before.json'), 'utf8'));
|
||||
const after = JSON.parse(await readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
|
||||
for (const employee of before.employees) employee.name = after.employees.find(item => item.id === employee.id).name;
|
||||
check(JSON.stringify(before) === JSON.stringify(after), 'fixture change is exclusively synthetic display names; contract/count/membership/profile preserved');
|
||||
check(errors.length === 0, 'browser console/runtime errors', errors);
|
||||
} finally {
|
||||
await writeFile(path.join(output, 'browser.json'), JSON.stringify({ browser: browser?.version(), results, errors, measurements }, null, 2));
|
||||
await browser?.close();
|
||||
for (const server of servers) await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
const failures = results.filter(result => result.status === 'FAIL');
|
||||
console.log(JSON.stringify({ checks: results.length, failures: failures.map(result => ({ name: result.name, issues: result.detail?.length })) }, null, 2));
|
||||
if (failures.length) process.exitCode = 1;
|
||||
@@ -0,0 +1,201 @@
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// Verify only the new card assets/layout; do not repeat Core/Summary suites.
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const output = path.join(root, 'test-results/org-chart-card-design');
|
||||
await mkdir(output, { recursive: true });
|
||||
const { chromium } = await import(process.env.ORGCHART_PLAYWRIGHT_MODULE ? pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href : 'playwright');
|
||||
const results = [], measurements = [], errors = [], servers = [];
|
||||
const check = (value, name, detail) => results.push({ name, status: value ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' };
|
||||
async function serve(directory) {
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://127.0.0.1');
|
||||
if (url.pathname === '/profile-fixture.svg') {
|
||||
response.setHeader('Content-Type', 'image/svg+xml');
|
||||
return response.end('<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="16" cy="16" r="16" fill="#45639f"/></svg>');
|
||||
}
|
||||
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
|
||||
const relative = path.relative(directory, file);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
|
||||
response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream');
|
||||
response.end(await readFile(file));
|
||||
} catch { response.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
servers.push(server);
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
async function ready(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
|
||||
return shadow?.querySelector('[data-card-asset]') && [...shadow.querySelectorAll('link')].every(link => !!link.sheet);
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||||
await Promise.all([...shadow.querySelectorAll('img')].map(img => img.decode().catch(() => {})));
|
||||
});
|
||||
}
|
||||
async function measure(page) {
|
||||
return page.evaluate(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||||
const cards = [...shadow.querySelectorAll('[data-card-asset]')].map(card => {
|
||||
const bounds = card.getBoundingClientRect(), background = card.querySelector('.hm-orgchart-card-background');
|
||||
const avatar = card.querySelector('.hm-orgchart-avatar').getBoundingClientRect();
|
||||
const logo = card.querySelector('.hm-orgchart-affiliation-mark')?.getBoundingClientRect();
|
||||
const style = getComputedStyle(card), issues = [];
|
||||
const contained = rect => rect.left >= bounds.left - 1 && rect.right <= bounds.right + 1 && rect.top >= bounds.top - 1 && rect.bottom <= bounds.bottom + 1;
|
||||
const overlap = (a, b) => b && Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1;
|
||||
if (!contained(avatar)) issues.push('avatar outside card');
|
||||
if (logo && !contained(logo)) issues.push('logo outside card');
|
||||
for (const element of card.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-job-title, .hm-orgchart-status-label')) {
|
||||
const range = document.createRange(); range.selectNodeContents(element);
|
||||
for (const rect of range.getClientRects()) {
|
||||
if (!contained(rect)) issues.push(`${element.className}: text outside card`);
|
||||
if (overlap(rect, avatar)) issues.push(`${element.className}: avatar overlap`);
|
||||
if (overlap(rect, logo)) issues.push(`${element.className}: logo overlap`);
|
||||
}
|
||||
}
|
||||
return { id: card.dataset.employeeId, department: card.dataset.cardAsset, size: card.dataset.cardAssetSize, kind: card.dataset.cardKind, inTopSlot: !!card.closest('.hm-orgchart-top-leaders'),
|
||||
width: bounds.width, height: bounds.height, backgroundLoaded: background.complete && background.naturalWidth > 0,
|
||||
naturalWidth: background.naturalWidth, naturalHeight: background.naturalHeight, embedded: background.src.startsWith('data:image/svg+xml'),
|
||||
border: style.borderWidth, radius: style.borderRadius, fill: style.backgroundColor,
|
||||
decoration: getComputedStyle(card, '::before').content, issues,
|
||||
logoLoaded: !!card.querySelector('.hm-orgchart-affiliation-mark img')?.naturalWidth,
|
||||
fallbackHidden: getComputedStyle(card.querySelector('.hm-orgchart-affiliation-fallback')).visibility === 'hidden',
|
||||
logoBackground: getComputedStyle(card.querySelector('.hm-orgchart-affiliation-mark img')).backgroundColor,
|
||||
avatarImage: !!card.querySelector('.hm-orgchart-avatar img')?.naturalWidth };
|
||||
});
|
||||
return { cards, documentOverflow: document.documentElement.scrollWidth > innerWidth + 1,
|
||||
legacyCards: [...shadow.querySelectorAll('.hm-orgchart-employee:not([data-card-asset])')].map(card => ({ id: card.dataset.employeeId, status: card.dataset.workStatus, kind: card.dataset.cardKind })),
|
||||
contrastCount: shadow.querySelectorAll('.hm-orgchart-contrast-result').length,
|
||||
headings: [...shadow.querySelectorAll('.hm-orgchart-color-department-name')].map(item => item.textContent) };
|
||||
});
|
||||
}
|
||||
function verify(value, label) {
|
||||
check(value.cards.length > 0 && value.cards.every(card => card.backgroundLoaded && card.embedded), `${label}: actual embedded SVGs load`);
|
||||
check(value.cards.every(card => card.width === (card.size === 'employee' ? 108 : 146) && card.height === (card.size === 'representative' ? 74 : card.size === 'leader' ? 68 : 52)
|
||||
&& card.naturalWidth === card.width && card.naturalHeight === card.height), `${label}: native card sizes/ratios`);
|
||||
check(value.cards.every(card => card.size === (card.kind === 'topLeader' ? 'representative' : card.kind === 'employee' ? 'employee' : 'leader')), `${label}: representative/leader/employee remain distinct`);
|
||||
check(value.cards.every(card => card.border === '0px' && card.radius === '0px' && card.fill === 'rgba(0, 0, 0, 0)' && card.decoration === 'none'), `${label}: original SVG shape without legacy box decoration`);
|
||||
check(value.cards.every(card => card.issues.length === 0), `${label}: text/avatar/logo bounds and collisions`, value.cards.filter(card => card.issues.length));
|
||||
check(value.cards.every(card => card.logoLoaded && card.fallbackHidden && card.logoBackground === 'rgba(0, 0, 0, 0)'), `${label}: existing transparent logos/fallback preserved`);
|
||||
check(!value.documentOverflow, `${label}: no page overflow`);
|
||||
}
|
||||
let browser;
|
||||
try {
|
||||
const origins = await Promise.all(['storybook-static', 'examples/org-chart/dist', 'examples/org-chart-react/dist'].map(directory => serve(path.join(root, directory))));
|
||||
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1, reducedMotion: 'reduce' });
|
||||
const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8'));
|
||||
const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart'));
|
||||
check(entries.filter(entry => !entry.tags?.includes('internal')).map(entry => entry.id).sort().join() === ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition'].map(id => `orgchart--${id}`).sort().join(), 'five public Storybook menus preserved');
|
||||
check(entries.filter(entry => entry.title === 'OrgChart/Content' && entry.tags.includes('internal')).length === 8, 'eight internal Stories preserved');
|
||||
const assets = await readdir(path.join(root, 'packages/org-chart/src/assets/cards'));
|
||||
const source = process.env.ORGCHART_CARD_SOURCE;
|
||||
if (source) for (const file of assets.filter(file => file.endsWith('.svg') && !['대표자.svg', '사업관리팀.svg', '사업관리팀-sm.svg'].includes(file))) {
|
||||
const hash = buffer => createHash('sha256').update(buffer).digest('hex');
|
||||
check(hash(await readFile(path.join(source, file))) === hash(await readFile(path.join(root, 'packages/org-chart/src/assets/cards', file))), `original SHA-256 preserved: ${file}`);
|
||||
}
|
||||
const routes = [
|
||||
['department-colors', `${origins[0]}/iframe.html?id=orgchart--department-colors&viewMode=story`],
|
||||
['full-composition', `${origins[0]}/iframe.html?id=orgchart--full-composition&viewMode=story`],
|
||||
['html', `${origins[1]}/p1.html`], ['react', `${origins[2]}/p1.html`],
|
||||
['file-classic', pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href],
|
||||
];
|
||||
for (const [label, url] of routes) {
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => errors.push({ label, message: error.message }));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push({ label, message: message.text() }); });
|
||||
await page.goto(url); await ready(page);
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
await page.setViewportSize({ width, height: 1000 }); await ready(page);
|
||||
const value = await measure(page); measurements.push({ label, width, ...value }); verify(value, `${label}/${width}`);
|
||||
if (label === 'department-colors') {
|
||||
check(value.cards.length === 13 && new Set(value.cards.filter(card => card.department !== 'representative').map(card => card.department)).size === 6, `${label}/${width}: six departments, both card sizes plus representative`);
|
||||
check(value.contrastCount === 24 && value.headings.length === 6, `${label}/${width}: existing contrast display retained`);
|
||||
} else {
|
||||
check(new Set(value.cards.filter(card => card.department !== 'representative').map(card => card.department)).size === 6, `${label}/${width}: all six mapped branches present`);
|
||||
check(value.cards.filter(card => card.department === 'representative').length === 1 && value.cards.some(card => card.id === 'mock-leader-00' && card.size === 'representative' && card.inTopSlot), `${label}/${width}: representative alone in top-leader slot`);
|
||||
check(value.cards.some(card => card.id === 'mock-leader-01' && card.department === 'business-management' && card.size === 'leader')
|
||||
&& value.cards.some(card => card.id === 'mock-employee-01' && card.department === 'business-management' && card.size === 'employee'), `${label}/${width}: business-management maps its own two sizes`);
|
||||
check(value.legacyCards.some(card => card.status !== 'active'), `${label}/${width}: supplementary status cards unchanged`);
|
||||
}
|
||||
}
|
||||
if (label === 'department-colors') {
|
||||
await page.screenshot({ path: path.join(output, 'department-colors-1440.png'), fullPage: true });
|
||||
await page.locator('.hm-orgchart-representative-preview').screenshot({ path: path.join(output, 'representative-card.png') });
|
||||
for (const [number, department] of [[0, 'business-management'], [1, 'business-planning'], [2, 'talent-growth'], [3, 'integrated-system'], [4, 'design-planning'], [5, 'solution-sales']]) {
|
||||
await page.locator('.hm-orgchart-preview-grid').nth(number).screenshot({ path: path.join(output, `${department}-cards.png`) });
|
||||
}
|
||||
}
|
||||
if (label === 'full-composition') {
|
||||
for (const department of ['business-management', 'business-planning', 'integrated-system', 'solution-sales']) {
|
||||
await page.locator(`.node[data-organization-id="${department}"]`).scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(output, `composition-${department}-1440.png`) });
|
||||
}
|
||||
}
|
||||
if (label === 'html') {
|
||||
// A local image fixture tests the retained profile data layer without
|
||||
// changing the source JSON or copying personal employee data.
|
||||
await page.evaluate(async () => {
|
||||
const next = structuredClone(window.OrgChartP1.data);
|
||||
for (const id of ['mock-leader-00', 'mock-leader-02', 'mock-employee-02']) next.employees.find(item => item.id === id).profileImage = `${location.origin}/profile-fixture.svg`;
|
||||
await window.OrgChartP1.setData(next);
|
||||
});
|
||||
await ready(page);
|
||||
const value = await measure(page);
|
||||
const profiles = value.cards.filter(card => ['mock-leader-00', 'mock-leader-02', 'mock-employee-02'].includes(card.id));
|
||||
check(profiles.length === 3 && profiles.every(card => card.avatarImage && card.issues.length === 0), 'representative/leader/employee profile images retain bounds over SVG');
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
const canonical = value => JSON.stringify(value.cards);
|
||||
const html = measurements.find(item => item.label === 'html' && item.width === width);
|
||||
const react = measurements.find(item => item.label === 'react' && item.width === width);
|
||||
const classic = measurements.find(item => item.label === 'file-classic' && item.width === width);
|
||||
check(canonical(html) === canonical(react) && canonical(html) === canonical(classic), `HTML/React/file:// shared card rendering parity/${width}`);
|
||||
}
|
||||
const colorPage = await context.newPage();
|
||||
const reference = await readFile(path.join(output, 'business-management-reference.png'));
|
||||
const vector = await readFile(path.join(root, 'packages/org-chart/src/assets/cards/사업관리팀.svg'));
|
||||
const comparison = await colorPage.evaluate(async ({ reference, vector }) => {
|
||||
const render = async (src) => {
|
||||
const image = new Image(); image.src = src; await image.decode();
|
||||
const canvas = document.createElement('canvas'); canvas.width = 408; canvas.height = 191;
|
||||
const ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0, 408, 191);
|
||||
return { pixels: ctx.getImageData(0, 0, 408, 191).data, png: canvas.toDataURL() };
|
||||
};
|
||||
const a = await render(reference), b = await render(vector), errors = [];
|
||||
for (let i = 0; i < a.pixels.length; i += 4) {
|
||||
for (let channel = 0; channel < 3; channel++) {
|
||||
const ca = a.pixels[i + channel] * a.pixels[i + 3] / 255 + 255 - a.pixels[i + 3];
|
||||
const cb = b.pixels[i + channel] * b.pixels[i + 3] / 255 + 255 - b.pixels[i + 3];
|
||||
errors.push(Math.abs(ca - cb));
|
||||
}
|
||||
}
|
||||
const mean = errors.reduce((sum, value) => sum + value, 0) / errors.length;
|
||||
errors.sort((x, y) => x - y);
|
||||
return { meanRGBChannelError: mean, percentile95ChannelError: errors[Math.floor(errors.length * 0.95)], generatedPreview: b.png };
|
||||
}, { reference: `data:image/png;base64,${reference.toString('base64')}`, vector: `data:image/svg+xml;base64,${vector.toString('base64')}` });
|
||||
await writeFile(path.join(output, 'business-management-generated.png'), Buffer.from(comparison.generatedPreview.split(',')[1], 'base64'));
|
||||
delete comparison.generatedPreview;
|
||||
await writeFile(path.join(output, 'business-management-comparison.json'), JSON.stringify({ ...comparison, method: 'Chrome canvas, reference and generated SVG at reference raster dimensions, RGB over white, all pixels. Geometry/antialias differences included; not a perceptual certification.' }, null, 2));
|
||||
check(comparison.meanRGBChannelError < 3 && comparison.percentile95ChannelError < 8, 'business-management PNG versus SVG rendered color similarity', comparison);
|
||||
await colorPage.close();
|
||||
check(errors.length === 0, 'browser console/runtime errors', errors);
|
||||
} finally {
|
||||
await writeFile(path.join(output, 'browser.json'), JSON.stringify({ browser: browser?.version(), results, errors, measurements }, null, 2));
|
||||
await browser?.close();
|
||||
for (const server of servers) await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
const failures = results.filter(result => result.status === 'FAIL');
|
||||
console.log(JSON.stringify({ checks: results.length, failures: failures.map(result => ({ name: result.name, detail: result.detail?.length })) }, null, 2));
|
||||
if (failures.length) process.exitCode = 1;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
// Only the eight final card requirements; existing 368-check evidence is untouched.
|
||||
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href);
|
||||
const output = 'test-results/org-chart-final-check';
|
||||
await mkdir(output, { recursive: true });
|
||||
const fixture = JSON.parse(await readFile('examples/org-chart/data-p1.json', 'utf8'));
|
||||
const results = [], measurements = [], errors = [];
|
||||
const check = (ok, name, actual) => results.push({ name, status: ok ? 'PASS' : 'FAIL', actual });
|
||||
const browser = await chromium.launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
|
||||
const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6031';
|
||||
const collisionRepair = process.argv.includes('--collision-repair');
|
||||
async function capture(story, scenario, data) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.on('pageerror', error => errors.push({ story, scenario, message: error.message }));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push({ story, scenario, message: message.text() }); });
|
||||
if (data) await page.route('**/examples/org-chart/data-p1.json*', route => route.fulfill({
|
||||
status: 200, contentType: 'application/javascript', body: `export default ${JSON.stringify(data)};`,
|
||||
}));
|
||||
await page.goto(`${origin}/iframe.html?id=orgchart--${story}&viewMode=story`);
|
||||
await page.waitForFunction(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
|
||||
return shadow?.querySelector('[data-card-asset] .hm-orgchart-person-name[data-name-overflow]')
|
||||
&& [...shadow.querySelectorAll('link')].every(link => link.sheet);
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||||
await Promise.all([...root.querySelectorAll('img')].map(image => image.decode().catch(() => {})));
|
||||
});
|
||||
const cards = await page.evaluate(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||||
const box = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height }; };
|
||||
// Collision checks use painted logo pixels, not the transparent container.
|
||||
const logoInk = mark => {
|
||||
const img = mark?.querySelector('img');
|
||||
if (!img?.naturalWidth) return null;
|
||||
const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
|
||||
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let left = canvas.width, right = -1, top = canvas.height, bottom = -1;
|
||||
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) if (pixels[(y * canvas.width + x) * 4 + 3] > 16) {
|
||||
left = Math.min(left, x); right = Math.max(right, x + 1); top = Math.min(top, y); bottom = Math.max(bottom, y + 1);
|
||||
}
|
||||
if (right < 0) return null;
|
||||
const b = box(img), scale = Math.min(b.width / canvas.width, b.height / canvas.height);
|
||||
const x = b.x + (b.width - canvas.width * scale) / 2, y = b.y + (b.height - canvas.height * scale) / 2;
|
||||
return { x: x + left * scale, y: y + top * scale, right: x + right * scale, bottom: y + bottom * scale };
|
||||
};
|
||||
return [...shadow.querySelectorAll('[data-card-asset]')].map(card => {
|
||||
const avatar = card.querySelector('.hm-orgchart-avatar'), text = card.querySelector('.hm-orgchart-person-text');
|
||||
const name = card.querySelector('.hm-orgchart-person-name'), position = card.querySelector('.hm-orgchart-person-role');
|
||||
const row = card.querySelector('.hm-orgchart-position-row');
|
||||
const badge = card.querySelector('.hm-orgchart-job-title'), attrs = card.querySelector('.hm-orgchart-person-attributes');
|
||||
const logo = card.querySelector('.hm-orgchart-affiliation-mark');
|
||||
const nameStyle = getComputedStyle(name), positionStyle = getComputedStyle(position), cardStyle = getComputedStyle(card);
|
||||
const a = box(avatar), t = box(text), n = box(name), p = box(position), b = badge && box(badge), l = logoInk(logo);
|
||||
const overlap = (u, v) => Math.min(u.right, v.right) - Math.max(u.x, v.x) > 1 && Math.min(u.bottom, v.bottom) - Math.max(u.y, v.y) > 1;
|
||||
const collisions = [];
|
||||
if (overlap(a, t)) collisions.push('avatar/text');
|
||||
if (b && overlap(b, a)) collisions.push('badge/avatar');
|
||||
if (b && overlap(b, n)) collisions.push('badge/name');
|
||||
if (l && overlap(l, a)) collisions.push('logo/avatar');
|
||||
if (l && overlap(l, n)) collisions.push('logo/name');
|
||||
if (l && b && overlap(l, b)) collisions.push('logo/badge');
|
||||
if (l) {
|
||||
const range = document.createRange(); range.selectNodeContents(position);
|
||||
for (const r of range.getClientRects()) if (overlap(l, { x: r.x, y: r.y, right: r.right, bottom: r.bottom })) collisions.push('logo/position');
|
||||
}
|
||||
const bs = badge && getComputedStyle(badge);
|
||||
return { id: card.dataset.employeeId, size: card.dataset.cardAssetSize, bounds: box(card), avatar: a,
|
||||
columnGap: cardStyle.columnGap, photoTextGap: t.x - a.right, textGap: getComputedStyle(text).rowGap,
|
||||
actualTextGap: n.y - p.bottom, nameColor: nameStyle.color,
|
||||
row: row ? { display: getComputedStyle(row).display, alignItems: getComputedStyle(row).alignItems, gap: getComputedStyle(row).columnGap,
|
||||
nameGap: n.y - box(row).bottom, badgeGap: b ? b.x - p.right : null, badgeMargin: bs?.margin ?? null, children: row.children.length } : null,
|
||||
nameMargin: nameStyle.margin, namePadding: nameStyle.padding, positionMargin: positionStyle.margin, positionPadding: positionStyle.padding,
|
||||
badge: badge ? { text: badge.textContent, fontSize: bs.fontSize, fontWeight: bs.fontWeight,
|
||||
color: bs.color, background: bs.backgroundColor, bounds: b } : null,
|
||||
attributesHeight: box(attrs).height, attributesPosition: getComputedStyle(attrs).position,
|
||||
collisions };
|
||||
});
|
||||
});
|
||||
measurements.push({ story, scenario, cards });
|
||||
for (const card of cards) {
|
||||
const prefix = `${story}/${scenario}/${card.id}/${card.size}`;
|
||||
if (!collisionRepair) {
|
||||
if (card.size === 'leader') check(card.columnGap === '10px' && Math.abs(card.photoTextGap - 10) < 0.1, `${prefix}: leader gap`, [card.columnGap, card.photoTextGap]);
|
||||
if (card.size === 'employee') {
|
||||
check(card.columnGap === '4px' && Math.abs(card.photoTextGap - 4) < 0.1, `${prefix}: employee gap`, [card.columnGap, card.photoTextGap]);
|
||||
check(card.nameColor === 'rgb(50, 50, 50)', `${prefix}: name color`, card.nameColor);
|
||||
}
|
||||
check(card.textGap === '0px' && Math.abs(card.actualTextGap) < 0.1 && [card.nameMargin, card.namePadding, card.positionMargin, card.positionPadding].every(value => value === '0px'), `${prefix}: text gap`, card.actualTextGap);
|
||||
const person = (data ?? fixture).employees.find(item => item.id === card.id);
|
||||
check(card.badge?.text === person.jobTitle || (!person.jobTitle && !card.badge), `${prefix}: jobTitle-driven badge`, { jobTitle: person.jobTitle, badge: card.badge?.text ?? null });
|
||||
if (!person.jobTitle) check(card.attributesHeight === 0 && card.attributesPosition === 'absolute', `${prefix}: no empty badge space`, card.attributesHeight);
|
||||
if (card.badge && card.size !== 'representative') check(card.badge.fontSize === (card.size === 'leader' ? '9px' : '8.5px') && card.badge.fontWeight === (card.size === 'leader' ? '700' : '600'), `${prefix}: badge typography`, card.badge);
|
||||
if (card.size !== 'representative') {
|
||||
check(card.row?.display === 'flex' && card.row.alignItems === 'center' && card.row.gap === '4px' && Math.abs(card.row.nameGap) < 0.1, `${prefix}: position/badge row and zero name gap`, card.row);
|
||||
if (card.badge) check(Math.abs(card.row.badgeGap - 4) < 0.1 && card.row.badgeMargin === '0px', `${prefix}: actual position/badge gap and margin`, card.row);
|
||||
else check(card.row.children === 1, `${prefix}: position only without badge`, card.row.children);
|
||||
}
|
||||
const dimensions = card.size === 'employee' ? [108, 52, 24] : card.size === 'leader' ? [146, 68, 32] : [146, 74, 36];
|
||||
check(card.bounds.width === dimensions[0] && card.bounds.height === dimensions[1] && card.avatar.width === dimensions[2] && card.avatar.height === dimensions[2], `${prefix}: preserved dimensions`, dimensions);
|
||||
}
|
||||
check(card.collisions.length === 0, `${prefix}: related collisions`, card.collisions);
|
||||
}
|
||||
if (!collisionRepair && (scenario === 'normal' || scenario === 'roles')) await page.screenshot({ path: `${output}/${story}-${scenario}.png`, fullPage: true });
|
||||
await page.close();
|
||||
return cards;
|
||||
}
|
||||
try {
|
||||
const sample = await capture('department-colors', 'normal');
|
||||
await capture('full-composition', 'normal');
|
||||
if (!collisionRepair) {
|
||||
const smallIds = sample.filter(card => card.size === 'employee').slice(0, 2).map(card => card.id);
|
||||
check(smallIds.length === 2, 'two small-card role fixtures available', smallIds);
|
||||
const roles = structuredClone(fixture);
|
||||
smallIds.forEach((id, index) => { roles.employees.find(item => item.id === id).jobTitle = index ? '셀장' : '팀장'; });
|
||||
const noRole = structuredClone(fixture);
|
||||
const leaderId = sample.find(card => card.size === 'leader').id;
|
||||
noRole.employees.find(item => item.id === leaderId).jobTitle = null;
|
||||
for (const story of ['department-colors', 'full-composition']) {
|
||||
const cards = await capture(story, 'roles', roles);
|
||||
for (const [index, id] of smallIds.entries()) check(cards.some(card => card.id === id && card.size === 'employee' && card.badge?.text === (index ? '셀장' : '팀장')), `${story}: small ${index ? 'cell' : 'team'} badge`, id);
|
||||
const empty = await capture(story, 'no-role', noRole);
|
||||
check(empty.some(card => card.id === leaderId && !card.badge && card.attributesHeight === 0), `${story}: leader without role has no badge/space`, leaderId);
|
||||
}
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
check(errors.length === 0, 'browser console/runtime errors', errors);
|
||||
const baseline = JSON.parse(await readFile(`${output}/baseline.json`, 'utf8'));
|
||||
const changed = [];
|
||||
for (const [file, hash] of Object.entries(baseline)) {
|
||||
const current = createHash('sha256').update(await readFile(file)).digest('hex');
|
||||
if (current !== hash) changed.push(file);
|
||||
}
|
||||
if (!collisionRepair) check(changed.length === 3 && changed.includes('packages/org-chart/src/core/employee-card.ts') && changed.includes('packages/org-chart/src/styles/employee-card.css') && changed.includes('packages/org-chart/src/styles/summary-sections.css'), 'preserved SVG/logo/JSON/API/Stories/368 evidence/Guard; only 3 existing files changed', changed);
|
||||
await writeFile(`${output}/${collisionRepair ? 'card-collision-repair' : 'browser'}.json`, JSON.stringify({ results, measurements, errors, changed, fixtureNote: 'Role/no-role scenarios alter only intercepted in-memory browser JSON modules; repository JSON is unchanged.' }, null, 2));
|
||||
console.log(JSON.stringify({ checks: results.length, pass: results.filter(item => item.status === 'PASS').length, failures: results.filter(item => item.status === 'FAIL'), errors, changed }, null, 2));
|
||||
if (results.some(item => item.status === 'FAIL')) process.exitCode = 1;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href);
|
||||
const output = 'test-results/org-chart-final-check';
|
||||
const baselineMode = process.argv.includes('--baseline');
|
||||
const repairMode = process.argv.includes('--geometry-repair');
|
||||
const browser = await chromium.launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
|
||||
const measurements = [], results = [], errors = [];
|
||||
const check = (ok, name, actual) => results.push({ name, status: ok ? 'PASS' : 'FAIL', actual });
|
||||
const previous = baselineMode ? [] : JSON.parse(await readFile(`${output}/logo-before.json`, 'utf8'));
|
||||
try {
|
||||
for (const story of ['department-colors', 'full-composition']) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
let expectedImageFailure = false;
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('console', item => { if (item.type() === 'error' && !(expectedImageFailure && item.text().includes('ERR_INVALID_URL'))) errors.push(item.text()); });
|
||||
await page.goto(`${process.env.ORGCHART_STORY_ORIGIN}/iframe.html?id=orgchart--${story}&viewMode=story`);
|
||||
await page.waitForFunction(() => {
|
||||
const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
|
||||
return root?.querySelector('[data-card-asset]') && [...root.querySelectorAll('link')].every(link => link.sheet);
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||||
await Promise.all([...root.querySelectorAll('img')].map(image => image.decode().catch(() => {})));
|
||||
});
|
||||
const logos = await page.evaluate(() => {
|
||||
const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||||
const rect = element => { const b = element.getBoundingClientRect(); return { x: b.x, y: b.y, width: b.width, height: b.height }; };
|
||||
return [...root.querySelectorAll('[data-card-kind] .hm-orgchart-affiliation-mark')].map(mark => {
|
||||
const img = mark.querySelector('img'), style = getComputedStyle(mark), fallback = mark.querySelector('.hm-orgchart-affiliation-fallback');
|
||||
return { id: mark.closest('[data-card-kind]').dataset.employeeId, kind: mark.closest('[data-card-kind]').dataset.cardKind,
|
||||
bounds: rect(mark), image: img ? rect(img) : null, imageFit: img && getComputedStyle(img).objectFit,
|
||||
background: style.backgroundColor, borders: [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth],
|
||||
borderStyle: style.borderTopStyle, shadow: style.boxShadow,
|
||||
imageBackground: img && getComputedStyle(img).backgroundColor,
|
||||
before: getComputedStyle(mark, '::before').content, after: getComputedStyle(mark, '::after').content,
|
||||
loaded: !!img?.naturalWidth, fallbackVisibility: getComputedStyle(fallback).visibility };
|
||||
});
|
||||
});
|
||||
measurements.push({ story, logos });
|
||||
if (!baselineMode) {
|
||||
for (const [index, logo] of logos.entries()) {
|
||||
const before = previous.find(item => item.story === story).logos[index];
|
||||
if (!repairMode) check(logo.background === 'rgba(0, 0, 0, 0)' && logo.borders.every(value => value === '0px') && logo.borderStyle === 'none' && logo.shadow === 'none', `${story}/${logo.id}: transparent borderless container`, logo);
|
||||
check(JSON.stringify(logo.bounds) === JSON.stringify(before.bounds) && JSON.stringify(logo.image) === JSON.stringify(before.image) && logo.imageFit === 'contain', `${story}/${logo.id}: unchanged container/image geometry`, { before, after: logo });
|
||||
if (!repairMode) check(logo.loaded && logo.fallbackVisibility === 'hidden' && logo.imageBackground === 'rgba(0, 0, 0, 0)' && ['none', 'normal'].includes(logo.before) && ['none', 'normal'].includes(logo.after), `${story}/${logo.id}: loaded transparent image and no fallback/decorations`, logo);
|
||||
}
|
||||
await page.locator('[data-employee-id="mock-leader-04"]').screenshot({ path: `${output}/${story}-logo-card.png` });
|
||||
expectedImageFailure = true;
|
||||
const failure = await page.evaluate(async () => {
|
||||
const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||||
const mark = root.querySelector('[data-card-asset] .hm-orgchart-affiliation-mark');
|
||||
mark.querySelector('img').src = 'data:image/png;base64,invalid-image';
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const fallback = mark.querySelector('.hm-orgchart-affiliation-fallback');
|
||||
return { noImage: !mark.querySelector('img'), available: mark.dataset.logoAvailable, visibility: getComputedStyle(fallback).visibility, text: fallback.textContent };
|
||||
});
|
||||
if (!repairMode) check(failure.noImage && failure.available === 'false' && failure.visibility === 'visible' && !!failure.text, `${story}: failed image retains fallback`, failure);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
if (baselineMode) await writeFile(`${output}/logo-before.json`, JSON.stringify(measurements, null, 2));
|
||||
else {
|
||||
check(errors.length === 0, 'browser console/runtime errors', errors);
|
||||
await writeFile(`${output}/${repairMode ? 'logo-geometry-repair' : 'logo-browser'}.json`, JSON.stringify({ results, measurements, errors }, null, 2));
|
||||
console.log(JSON.stringify({ checks: results.length, pass: results.filter(item => item.status === 'PASS').length, failures: results.filter(item => item.status === 'FAIL'), errors }, null, 2));
|
||||
if (results.some(item => item.status === 'FAIL')) process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href);
|
||||
const output = 'test-results/org-chart-final-check';
|
||||
const baselineMode = process.argv.includes('--baseline');
|
||||
const colorsRepair = process.argv.includes('--colors-repair');
|
||||
const browser = await chromium.launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
|
||||
const results = [], measurements = [], errors = [];
|
||||
const check = (ok, name, actual) => results.push({ name, status: ok ? 'PASS' : 'FAIL', actual });
|
||||
async function measure(url, label) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.on('pageerror', error => errors.push({ label, message: error.message }));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push({ label, message: message.text() }); });
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => {
|
||||
const root = document.querySelector('.hm-orgchart-host')?.shadowRoot;
|
||||
return root?.querySelector('.hm-orgchart-status-group') && [...root.querySelectorAll('link')].every(link => link.sheet);
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
const root = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
await Promise.all([...root.querySelectorAll('img')].map(image => image.decode().catch(() => {})));
|
||||
});
|
||||
const value = await page.evaluate(() => {
|
||||
const root = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
const properties = (element, keys) => { const s = getComputedStyle(element); return Object.fromEntries(keys.map(key => [key, s[key]])); };
|
||||
const typography = element => properties(element, ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'margin', 'padding']);
|
||||
const box = element => { const b = element.getBoundingClientRect(); return { x: b.x, y: b.y, right: b.right, bottom: b.bottom }; };
|
||||
const overlap = (a, b) => Math.min(a.right, b.right) - Math.max(a.x, b.x) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.y, b.y) > 1;
|
||||
const organization = [...root.querySelectorAll('[data-card-asset]')].map(card => ({ id: card.dataset.employeeId,
|
||||
card: properties(card, ['backgroundImage', 'border', 'width', 'height', 'columnGap']),
|
||||
name: properties(card.querySelector('.hm-orgchart-person-name'), ['color', 'fontSize', 'fontWeight']),
|
||||
logo: properties(card.querySelector('.hm-orgchart-affiliation-mark img'), ['filter', 'opacity', 'width', 'height']) }));
|
||||
const groups = [...root.querySelectorAll('.hm-orgchart-status-group')].map(group => ({
|
||||
status: group.dataset.workStatus, titleText: group.querySelector('h3').textContent,
|
||||
title: properties(group.querySelector('h3'), ['backgroundImage', 'border', 'boxShadow']),
|
||||
cards: [...group.querySelectorAll('.hm-orgchart-employee')].map(card => {
|
||||
const role = card.querySelector('.hm-orgchart-person-role'), name = card.querySelector('.hm-orgchart-person-name');
|
||||
const avatar = card.querySelector('.hm-orgchart-avatar'), mark = card.querySelector('.hm-orgchart-affiliation-mark'), image = mark.querySelector('img');
|
||||
const collisions = [];
|
||||
const roleRange = document.createRange(); roleRange.selectNodeContents(role);
|
||||
const nameRange = document.createRange(); nameRange.selectNodeContents(name);
|
||||
for (const [kind, range] of [['role', roleRange], ['name', nameRange]]) for (const r of range.getClientRects()) {
|
||||
const textBox = { x: r.x, y: r.y, right: r.right, bottom: r.bottom };
|
||||
if (overlap(textBox, box(avatar))) collisions.push(`${kind}/avatar`);
|
||||
if (overlap(textBox, box(image))) collisions.push(`${kind}/logo`);
|
||||
}
|
||||
return { id: card.dataset.employeeId, card: properties(card, ['margin', 'backgroundImage', 'border', 'borderRadius', 'cursor', 'columnGap', 'filter', 'opacity']),
|
||||
roleColor: getComputedStyle(role).color, nameColor: getComputedStyle(name).color,
|
||||
roleTypography: typography(role), nameTypography: typography(name),
|
||||
logo: properties(image, ['filter', 'opacity']), mark: properties(mark, ['backgroundColor', 'border', 'boxShadow', 'filter', 'opacity']), collisions };
|
||||
})
|
||||
}));
|
||||
return { organization, groups, radiusVariable: getComputedStyle(root.querySelector('.hm-orgchart-shell')).getPropertyValue('--br-3_5').trim(),
|
||||
resolvedRadius: getComputedStyle(root.querySelector('.hm-orgchart-shell')).getPropertyValue('--hm-orgchart-resolved-radius-container').trim() };
|
||||
});
|
||||
if (!baselineMode) await page.locator('.hm-orgchart-status-summary').screenshot({ path: `${output}/${label}-status.png` });
|
||||
await page.close();
|
||||
return value;
|
||||
}
|
||||
let server;
|
||||
try {
|
||||
const storyUrl = `${process.env.ORGCHART_STORY_ORIGIN}/iframe.html?id=orgchart--full-composition&viewMode=story`;
|
||||
const react = await measure(storyUrl, 'react');
|
||||
if (baselineMode) await writeFile(`${output}/status-before.json`, JSON.stringify(react, null, 2));
|
||||
else {
|
||||
const baseline = JSON.parse(await readFile(`${output}/status-before.json`, 'utf8'));
|
||||
if (!colorsRepair) check(JSON.stringify(react.organization) === JSON.stringify(baseline.organization), 'normal organization cards and colored logos unchanged', react.organization.length);
|
||||
const directory = path.resolve('examples/org-chart/dist');
|
||||
server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const file = path.resolve(directory, '.' + decodeURIComponent(new URL(req.url, 'http://localhost').pathname));
|
||||
if (path.relative(directory, file).startsWith('..')) return res.writeHead(403).end();
|
||||
res.setHeader('Content-Type', ({ '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.ttf': 'font/ttf' })[path.extname(file)] ?? 'application/octet-stream');
|
||||
res.end(await readFile(file));
|
||||
} catch { res.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const html = await measure(`http://127.0.0.1:${server.address().port}/p1.html`, 'html');
|
||||
measurements.push({ label: 'react', ...react }, { label: 'html', ...html });
|
||||
check(JSON.stringify(react.groups) === JSON.stringify(html.groups), 'HTML and React status groups computed styles match', react.groups.map(group => group.status));
|
||||
for (const group of react.groups) {
|
||||
const before = baseline.groups.find(item => item.status === group.status);
|
||||
if (colorsRepair) {
|
||||
for (const card of group.cards) check(card.roleColor === 'rgb(85, 95, 106)' && card.nameColor === 'rgb(62, 70, 80)', `${group.status}/${card.id}: repaired text colors`, [card.roleColor, card.nameColor]);
|
||||
continue;
|
||||
}
|
||||
check(group.title.backgroundImage === 'linear-gradient(rgb(135, 144, 156) 0%, rgb(104, 114, 126) 100%)' && group.title.border === '1px solid rgba(92, 101, 112, 0.52)' && group.title.boxShadow === 'rgba(75, 84, 96, 0.18) 0px 7px 16px 0px', `${group.status}: title gradient/border/shadow`, group.title);
|
||||
for (const card of group.cards) {
|
||||
const old = before.cards.find(item => item.id === card.id);
|
||||
check(card.card.backgroundImage === 'linear-gradient(rgb(244, 245, 246) 0%, rgb(223, 227, 231) 100%)' && card.card.border === '1px solid rgba(120, 130, 142, 0.35)' && card.card.borderRadius === '4px' && card.card.margin === '0px' && card.card.cursor === 'pointer', `${group.status}/${card.id}: card surface`, card.card);
|
||||
check(card.roleColor === 'rgb(85, 95, 106)' && card.nameColor === 'rgb(62, 70, 80)', `${group.status}/${card.id}: text colors`, [card.roleColor, card.nameColor]);
|
||||
check(JSON.stringify(card.roleTypography) === JSON.stringify(old.roleTypography) && JSON.stringify(card.nameTypography) === JSON.stringify(old.nameTypography) && card.card.columnGap === old.card.columnGap, `${group.status}/${card.id}: existing typography and gap preserved`, { role: card.roleTypography, name: card.nameTypography, gap: card.card.columnGap });
|
||||
check(card.logo.filter === 'grayscale(1)' && card.logo.opacity === '0.72' && card.mark.filter === 'none' && card.mark.opacity === '1' && card.card.filter === 'none' && card.card.opacity === '1', `${group.status}/${card.id}: image-only filter/opacity`, card.logo);
|
||||
check(card.mark.backgroundColor === 'rgba(0, 0, 0, 0)' && card.mark.border === '0px none rgb(89, 101, 117)' && card.mark.boxShadow === 'none', `${group.status}/${card.id}: transparent logo frame`, card.mark);
|
||||
check(card.collisions.length === 0, `${group.status}/${card.id}: profile/text/logo collisions`, card.collisions);
|
||||
}
|
||||
check(group.titleText === before.titleText && group.cards.length === before.cards.length, `${group.status}: same employee IDs/counts`, group.cards.map(card => card.id));
|
||||
}
|
||||
check(errors.length === 0, 'browser errors', errors);
|
||||
await writeFile(`${output}/${colorsRepair ? 'status-colors-repair' : 'status-browser'}.json`, JSON.stringify({ results, measurements, errors }, null, 2));
|
||||
console.log(JSON.stringify({ checks: results.length, pass: results.filter(item => item.status === 'PASS').length, failures: results.filter(item => item.status === 'FAIL'), errors }, null, 2));
|
||||
if (results.some(item => item.status === 'FAIL')) process.exitCode = 1;
|
||||
}
|
||||
} finally { await browser.close(); if (server) await new Promise(resolve => server.close(resolve)); }
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { parse } from '@babel/parser';
|
||||
import postcss from 'postcss';
|
||||
import { themeAnalysis } from '../../scripts/guard-theme-analysis.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const script = fileURLToPath(import.meta.url);
|
||||
const rawFile = path.join(root, 'test-results/org-chart-p1/guard-storybook-menu-recheck.json');
|
||||
const output = path.join(root, 'test-results/org-chart-quality/guard-reproduction.json');
|
||||
const started = performance.now();
|
||||
const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
const rawHashBefore = hash(rawFile);
|
||||
const raw = JSON.parse(fs.readFileSync(rawFile, 'utf8'));
|
||||
const analyzerHashBefore = hash(path.join(root, 'scripts/guard-theme-analysis.mjs'));
|
||||
|
||||
function walk(node, visitor, parent = null, field = null) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (node.type) visitor(node, parent, field);
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (['loc', 'start', 'end', 'comments', 'tokens', 'extra'].includes(key)) continue;
|
||||
if (Array.isArray(value)) value.forEach(item => walk(item, visitor, node, key));
|
||||
else if (value && typeof value === 'object') walk(value, visitor, node, key);
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory fixtures use the function's existing injected parser interface.
|
||||
* The script itself is a readable CSS-file sentinel because the real analyzer
|
||||
* reads files before invoking its parser. The parser returns the supplied
|
||||
* fixture CSS AST; no fixture files or production code are changed. */
|
||||
function analyze(label, jsSource, cssSource) {
|
||||
const jsFile = `memory://${label}.js`;
|
||||
const records = [{ file: jsFile, edges: [], ast: parse(jsSource, { sourceType: 'module' }) }];
|
||||
if (cssSource !== undefined) records.push({ file: script, edges: [], css: true });
|
||||
const issues = [];
|
||||
const result = themeAnalysis({
|
||||
records, workspace: root,
|
||||
postcss: { parse: () => postcss.parse(cssSource ?? '', { from: `memory://${label}.css` }) },
|
||||
walk, selectedFile: () => true,
|
||||
add: (severity, code, file, node, message) => issues.push({
|
||||
severity, code, file: file === script ? `memory://${label}.css` : file,
|
||||
line: node?.loc?.start?.line ?? node?.source?.start?.line ?? 1, message,
|
||||
}),
|
||||
});
|
||||
return {
|
||||
label, fixture: { js: jsSource, css: cssSource ?? null },
|
||||
analyzerStatus: issues.some(issue => issue.severity === 'FAIL') ? 'FAIL'
|
||||
: issues.length ? 'NOT VERIFIED' : 'PASS',
|
||||
definitions: result.definitions, references: result.references.map(ref => ({
|
||||
...ref, file: ref.file === script ? `memory://${label}.css` : ref.file,
|
||||
})), dynamic: result.dynamic, incomplete: result.incomplete, issues,
|
||||
};
|
||||
}
|
||||
|
||||
const measurements = [];
|
||||
const assertions = [];
|
||||
function check(name, condition) {
|
||||
assertions.push({ name, status: condition ? 'PASS' : 'FAIL' });
|
||||
assert.ok(condition, name);
|
||||
}
|
||||
let failure;
|
||||
try {
|
||||
const graphSource = 'const background = "var(--hm-orgchart-resolved-bg-surface)";';
|
||||
const excluded = analyze('shadow-css-excluded', graphSource);
|
||||
const included = analyze('shadow-css-included', graphSource,
|
||||
'.hm-orgchart-shell { --hm-orgchart-resolved-bg-surface: #ffffff; }');
|
||||
measurements.push(excluded, included);
|
||||
check('Excluded Shadow CSS reference reproduces TOKEN-001 FAIL',
|
||||
excluded.issues.some(issue => issue.code === 'TOKEN-001' && issue.severity === 'FAIL'));
|
||||
check('Connected CSS declaration resolves the identical reference',
|
||||
included.analyzerStatus === 'PASS' && included.definitions.includes('--hm-orgchart-resolved-bg-surface'));
|
||||
|
||||
const setterSource = 'element.style.setProperty("--hm-orgchart-current-border", "#194d82");\n'
|
||||
+ 'element.style.setProperty("--hm-orgchart-accent", "var(--hm-orgchart-current-border)");';
|
||||
const setterOnly = analyze('set-property-not-collected', setterSource);
|
||||
const setterWithCss = analyze('set-property-with-css-declaration', setterSource,
|
||||
'.hm-orgchart-group { --hm-orgchart-current-border: #194d82; }');
|
||||
measurements.push(setterOnly, setterWithCss);
|
||||
check('Actual literal setProperty definition is absent from analyzer definitions',
|
||||
!setterOnly.definitions.includes('--hm-orgchart-current-border') && setterOnly.analyzerStatus === 'FAIL');
|
||||
check('CSS definition resolves setProperty reference without changing JS fixture',
|
||||
setterWithCss.analyzerStatus === 'PASS');
|
||||
|
||||
const optional = analyze('optional-public-input-with-fallback', '',
|
||||
'.hm-orgchart-shell { --hm-orgchart-resolved-fg-primary: var(--hm-orgchart-fg-primary, #10243b); }');
|
||||
measurements.push(optional);
|
||||
const optionalReference = optional.references.find(ref => ref.name === '--hm-orgchart-fg-primary');
|
||||
check('Optional standalone input with valid fallback is still classified TOKEN-001 FAIL',
|
||||
optionalReference?.fallback === true && optionalReference.status === 'FAIL'
|
||||
&& optional.issues.some(issue => issue.code === 'TOKEN-001' && issue.severity === 'FAIL'));
|
||||
|
||||
// Diagnostic namespace control only. Renaming OrgChart inputs is NOT a fix.
|
||||
const otherNamespace = analyze('external-input-namespace-control', '',
|
||||
'.content { --resolved-fg-primary: var(--external-fg-primary, #10243b); }');
|
||||
measurements.push(otherNamespace);
|
||||
check('Same fallback shape outside HM namespace is NOT VERIFIED, not FAIL',
|
||||
otherNamespace.analyzerStatus === 'NOT VERIFIED' && otherNamespace.references[0]?.fallback === true);
|
||||
check('Historical raw report remains FAIL with the original three TOKEN-001 findings',
|
||||
raw.status === 'FAIL' && raw.staticStatus === 'FAIL'
|
||||
&& raw.issues.filter(issue => issue.code === 'TOKEN-001').length === 3);
|
||||
check('Original Guard output and analyzer source hashes remain unchanged',
|
||||
hash(rawFile) === rawHashBefore && hash(path.join(root, 'scripts/guard-theme-analysis.mjs')) === analyzerHashBefore);
|
||||
} catch (error) {
|
||||
failure = { name: error.name, message: error.message };
|
||||
}
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
purpose: 'Minimal reproduction of existing analyzer behavior; assertion PASS is NOT OrgChart quality or Guard PASS',
|
||||
reproductionStatus: failure ? 'FAIL' : 'PASS',
|
||||
generatedAt: new Date().toISOString(), durationMs: Math.round((performance.now() - started) * 100) / 100,
|
||||
assertions, measurements,
|
||||
originalGuard: {
|
||||
file: 'test-results/org-chart-p1/guard-storybook-menu-recheck.json',
|
||||
sha256Before: rawHashBefore, sha256After: hash(rawFile), status: raw.status, staticStatus: raw.staticStatus,
|
||||
relevantIssues: raw.issues.filter(issue => ['TOKEN-001', 'TOKEN-002'].includes(issue.code)),
|
||||
},
|
||||
analyzer: { file: 'scripts/guard-theme-analysis.mjs', sha256Before: analyzerHashBefore,
|
||||
sha256After: hash(path.join(root, 'scripts/guard-theme-analysis.mjs')) },
|
||||
scope: 'In-memory fixtures passed directly to the actual exported themeAnalysis; no full Guard suite or browser checks',
|
||||
limitations: [
|
||||
'This demonstrates analyzer limitations; it does not implement a revised Guard policy',
|
||||
'Fixture graph inclusion is controlled directly; real Shadow URL dataflow is not implemented or rerun',
|
||||
'The CSS declaration control is a fixture, not a production dummy declaration proposal',
|
||||
'The external namespace control is diagnostic; production namespace renaming would be a bypass',
|
||||
'Original Guard FAIL remains unchanged; runtime evidence cannot overwrite that FAIL',
|
||||
'Common Guard changes require separate user approval',
|
||||
],
|
||||
...(failure ? { failure } : {}),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ reproductionStatus: report.reproductionStatus, assertions: assertions.length,
|
||||
measurements: measurements.length, originalGuardStatus: raw.status, output: path.relative(root, output) }));
|
||||
if (failure) process.exitCode = 1;
|
||||
@@ -0,0 +1,568 @@
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// Verification tooling is supplied by the environment, never shipped in Core.
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const output = path.join(root, 'test-results/org-chart-p1');
|
||||
await mkdir(output, { recursive: true });
|
||||
const fixture = JSON.parse(await readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8'));
|
||||
const modulePath = process.env.ORGCHART_PLAYWRIGHT_MODULE;
|
||||
const { chromium } = await import(modulePath ? pathToFileURL(modulePath).href : 'playwright');
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.ttf': 'font/ttf', '.woff2': 'font/woff2', '.png': 'image/png', '.svg': 'image/svg+xml' };
|
||||
async function serve(directory) {
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://127.0.0.1');
|
||||
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
|
||||
const relative = path.relative(directory, file);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; }
|
||||
response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream');
|
||||
response.end(await readFile(file));
|
||||
} catch { response.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
return { server, origin: `http://127.0.0.1:${server.address().port}` };
|
||||
}
|
||||
|
||||
const results = [], errors = [], metrics = [], snapshots = [];
|
||||
const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
|
||||
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
||||
const clone = data => structuredClone(data);
|
||||
const dataWith = (data, version) => { const next = clone(data); next.metadata.dataVersion = version; return next; };
|
||||
function captureErrors(page, label) {
|
||||
page.on('pageerror', error => errors.push({ label, type: 'pageerror', message: error.message }));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push({ label, type: 'console', message: message.text() }); });
|
||||
}
|
||||
|
||||
async function ready(page, transport, version) {
|
||||
await page.waitForFunction(({ transport, version }) => {
|
||||
const api = transport === 'vanilla' ? window.OrgChartP1 : window.OrgChartP1React;
|
||||
const state = transport === 'vanilla' ? api?.chart?.getState() : api?.events?.at(-1);
|
||||
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
|
||||
return state?.status === 'ready' && state.dataVersion === version && shadow?.querySelector('.hm-orgchart-composition .node');
|
||||
}, { transport, version }, { timeout: 20000 });
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
|
||||
await Promise.all([...shadow.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(image => image.complete ? Promise.resolve() : new Promise(resolve => {
|
||||
image.addEventListener('load', resolve, { once: true }); image.addEventListener('error', resolve, { once: true });
|
||||
})));
|
||||
});
|
||||
}
|
||||
async function setData(page, transport, data) {
|
||||
await page.evaluate(async ({ transport, data }) => {
|
||||
const api = transport === 'vanilla' ? window.OrgChartP1 : window.OrgChartP1React;
|
||||
await api.setData(data);
|
||||
}, { transport, data });
|
||||
await ready(page, transport, data.metadata.dataVersion);
|
||||
}
|
||||
|
||||
/** Transport-neutral snapshot: no generated IDs, stylesheet hashes or host URLs. */
|
||||
async function snapshot(page) {
|
||||
return page.evaluate(() => {
|
||||
const host = document.querySelector('.hm-orgchart-host');
|
||||
const shadow = host.shadowRoot;
|
||||
const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
|
||||
const employees = scope => [...scope.querySelectorAll('.hm-orgchart-employee')].map(card => ({
|
||||
id: card.dataset.employeeId, kind: card.dataset.cardKind, status: card.dataset.workStatus,
|
||||
name: text(card.querySelector('.hm-orgchart-person-name')), position: text(card.querySelector('.hm-orgchart-person-role')),
|
||||
jobTitle: text(card.querySelector('.hm-orgchart-job-title')),
|
||||
affiliation: card.querySelector('.hm-orgchart-affiliation-mark')?.dataset.affiliationId ?? null,
|
||||
}));
|
||||
return {
|
||||
heading: text(shadow.querySelector('.hm-orgchart-heading')),
|
||||
title: text(shadow.querySelector('.hm-orgchart-title')),
|
||||
hub: text(shadow.querySelector('.hm-orgchart-hub')),
|
||||
nodes: [...shadow.querySelectorAll('.node')].map(node => {
|
||||
const hierarchy = node.closest('.hierarchy');
|
||||
const parent = hierarchy?.parentElement?.closest('.hierarchy')?.querySelector(':scope > .node');
|
||||
return { id: node.dataset.organizationId, type: node.dataset.organizationType, depth: Number(node.dataset.depth), parentId: parent?.dataset.organizationId ?? null,
|
||||
name: text(node.querySelector('.hm-orgchart-organization-name')), employees: employees(node) };
|
||||
}),
|
||||
topLeaders: employees(shadow.querySelector('.hm-orgchart-top-leaders') ?? document.createElement('div')),
|
||||
components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component),
|
||||
positions: shadow.querySelector('.hm-orgchart-position-summary') ? {
|
||||
total: text(shadow.querySelector('.hm-orgchart-position-summary .hm-orgchart-raw-total')),
|
||||
policy: [...shadow.querySelectorAll('.hm-orgchart-position-summary .hm-orgchart-policy')].map(text),
|
||||
headers: [...shadow.querySelectorAll('.hm-orgchart-position-table thead th')].map(text),
|
||||
rows: [...shadow.querySelectorAll('.hm-orgchart-position-table tbody tr')].map(row => ({ id: row.dataset.organizationId, name: text(row.querySelector('th')), cells: [...row.querySelectorAll('td')].map(text) })),
|
||||
totals: [...shadow.querySelectorAll('.hm-orgchart-position-table tfoot td')].map(text),
|
||||
statuses: text(shadow.querySelector('.hm-orgchart-status-totals')),
|
||||
} : null,
|
||||
affiliations: [...shadow.querySelectorAll('.hm-orgchart-affiliation-item')].map(item => ({
|
||||
id: item.dataset.affiliationId, count: text(item.querySelector('dd')),
|
||||
label: item.querySelector('.hm-orgchart-affiliation-mark')?.getAttribute('aria-label'),
|
||||
logo: item.querySelector('.hm-orgchart-affiliation-mark img')?.getAttribute('src') ?? null,
|
||||
})),
|
||||
affiliationTotal: text(shadow.querySelector('.hm-orgchart-affiliation-summary .hm-orgchart-raw-total')),
|
||||
statuses: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(group => ({ status: group.dataset.workStatus,
|
||||
heading: text(group.querySelector('h3')), employees: employees(group), empty: text(group.querySelector('.hm-orgchart-content-empty')) })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function expectedCounts(data) {
|
||||
const employees = [...new Map(data.employees.map(employee => [employee.id, employee])).values()];
|
||||
const organizations = new Map(data.organizations.map(org => [org.id, org]));
|
||||
const positions = new Map(), affiliations = new Map(data.affiliations.map(item => [item.id, 0]));
|
||||
const direct = new Map(data.organizations.map(org => [org.id, 0]));
|
||||
const inclusive = new Map(direct), matrix = new Map(data.organizations.map(org => [org.id, new Map()]));
|
||||
const statuses = { active: 0, dispatched: 0, onLeave: 0 };
|
||||
for (const employee of employees) {
|
||||
positions.set(employee.position, (positions.get(employee.position) ?? 0) + 1);
|
||||
affiliations.set(employee.affiliationId, (affiliations.get(employee.affiliationId) ?? 0) + 1);
|
||||
direct.set(employee.organizationId, direct.get(employee.organizationId) + 1);
|
||||
const row = matrix.get(employee.organizationId); row.set(employee.position, (row.get(employee.position) ?? 0) + 1);
|
||||
statuses[employee.workStatus]++;
|
||||
let org = organizations.get(employee.organizationId);
|
||||
while (org) { inclusive.set(org.id, inclusive.get(org.id) + 1); org = organizations.get(org.parentId); }
|
||||
}
|
||||
return { total: employees.length, employees, positions, affiliations, direct, inclusive, matrix, statuses };
|
||||
}
|
||||
function checkCounts(current, data, label) {
|
||||
const expected = expectedCounts(data);
|
||||
const positionNames = current.positions.headers.slice(1, -2);
|
||||
check(current.positions.rows.length === data.organizations.length, `${label}: every organization has a cross-table row`);
|
||||
check(current.positions.rows.every(row => {
|
||||
const cells = row.cells.map(Number);
|
||||
return positionNames.every((position, index) => cells[index] === (expected.matrix.get(row.id)?.get(position) ?? 0))
|
||||
&& cells.at(-2) === expected.direct.get(row.id) && cells.at(-1) === expected.inclusive.get(row.id);
|
||||
}), `${label}: direct organization×position cells and inclusive counts match unique primary membership`, current.positions.rows);
|
||||
check(positionNames.every((position, index) => Number(current.positions.totals[index]) === expected.positions.get(position))
|
||||
&& Number(current.positions.totals.at(-2)) === expected.total && current.positions.totals.at(-1) === '—', `${label}: direct totals are disjoint; inclusive counts are not added`, current.positions.totals);
|
||||
check(current.positions.total.includes(`${expected.total}명`) && current.affiliationTotal.includes(`${expected.total}명`), `${label}: Position and Affiliation totals reuse the same Raw Count`);
|
||||
check(current.affiliations.length === data.affiliations.length && current.affiliations.every(item => parseInt(item.count) === expected.affiliations.get(item.id)), `${label}: every declared affiliation count is correct`, current.affiliations.map(({ id, count }) => ({ id, count })));
|
||||
check(current.statuses.every(group => {
|
||||
const matching = expected.employees.filter(employee => employee.workStatus === group.status);
|
||||
return parseInt(group.heading.split('·').at(-1)) === matching.length && same(group.employees.map(employee => employee.id).sort(), matching.map(employee => employee.id).sort());
|
||||
}), `${label}: status groups use original employee IDs without inflating total`, current.statuses.map(group => ({ status: group.status, ids: group.employees.map(employee => employee.id) })));
|
||||
check(current.positions.policy.some(text => text.includes('총원 미확정')) && current.positions.policy.some(text => text.includes('조직장') && text.includes('파견') && text.includes('휴직')), `${label}: Raw inclusion policy is visible; business total remains unapproved`);
|
||||
}
|
||||
|
||||
async function measure(page) {
|
||||
return page.evaluate(() => {
|
||||
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host');
|
||||
const shadow = host.shadowRoot;
|
||||
const viewport = shadow.querySelector('.hm-orgchart-viewport');
|
||||
const summary = shadow.querySelector('.hm-orgchart-summary-scroll');
|
||||
const dimensions = element => element && ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX });
|
||||
const textElements = [...shadow.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-organization-name')].filter(element => element.getBoundingClientRect().height > 0);
|
||||
const cards = [...shadow.querySelectorAll('.node')].map(element => element.getBoundingClientRect()).filter(rect => rect.height > 0);
|
||||
const sample = shadow.querySelector('.hm-orgchart-employee, .hm-orgchart-position-summary');
|
||||
return {
|
||||
document: dimensions(document.documentElement), host: dimensions(host), viewport: dimensions(viewport), summary: dimensions(summary),
|
||||
ids: [...shadow.querySelectorAll('.hm-orgchart-domain-id')].map(element => ({ text: element.textContent, title: element.title, whiteSpace: getComputedStyle(element).whiteSpace, height: element.clientHeight, scrollHeight: element.scrollHeight })),
|
||||
textFits: textElements.every(element => element.scrollWidth <= element.clientWidth + 1 && element.scrollHeight <= element.clientHeight + 1),
|
||||
textIssues: textElements.filter(element => element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1).map(element => ({ className: element.className, text: element.textContent, ...dimensions(element) })),
|
||||
nodeOverlap: cards.some((a, index) => cards.slice(index + 1).some(b => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1)),
|
||||
font: sample ? getComputedStyle(sample).fontFamily : null, foreground: sample ? getComputedStyle(sample).color : null,
|
||||
background: sample ? getComputedStyle(sample).backgroundColor : null, radius: sample ? getComputedStyle(sample).borderRadius : null,
|
||||
tokens: [...getComputedStyle(shadow.querySelector('.hm-orgchart-shell'))].filter(key => key.startsWith('--hm-orgchart-')).map(key => ({ key, value: getComputedStyle(shadow.querySelector('.hm-orgchart-shell')).getPropertyValue(key).trim() })),
|
||||
cssLoaded: [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet),
|
||||
portraitsFallback: [...shadow.querySelectorAll('.hm-orgchart-avatar')].every(avatar => avatar.textContent.trim() && !avatar.querySelector('img')),
|
||||
logos: [...shadow.querySelectorAll('.hm-orgchart-affiliation-item .hm-orgchart-affiliation-mark')].map(mark => ({ id: mark.dataset.affiliationId, available: mark.dataset.logoAvailable, src: mark.querySelector('img')?.getAttribute('src'), loaded: !!mark.querySelector('img')?.complete && mark.querySelector('img').naturalWidth > 0 })),
|
||||
navigationCount: shadow.querySelectorAll('nav, .hm-dashboard-header, [data-slot=sidebar], .hm-gnb, .hm-lnb').length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function styleIsolation(page, transport) {
|
||||
const evidence = await page.evaluate(async () => {
|
||||
const host = document.querySelector('.hm-orgchart-host');
|
||||
const shadow = host.shadowRoot;
|
||||
const sample = shadow.querySelector('.hm-orgchart-employee[data-card-kind=employee]');
|
||||
const signature = () => {
|
||||
const style = getComputedStyle(sample);
|
||||
return { foreground: style.color, background: style.backgroundColor, font: style.fontFamily, radius: style.borderRadius, border: style.borderTopColor };
|
||||
};
|
||||
const initial = signature();
|
||||
// Document CSS must be optional; private Shadow stylesheet links stay mounted.
|
||||
const documentLinks = [...document.querySelectorAll('link[rel=stylesheet]')];
|
||||
documentLinks.forEach(link => link.remove());
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
const withoutTheme = signature();
|
||||
for (const [property, value] of [['--hm-sem-bg-surface', '#ff0000'], ['--hm-sem-fg-primary', '#ff0000'], ['--hm-radius-container', '99px'], ['--hm-font-family-base', 'monospace']]) host.style.setProperty(property, value);
|
||||
const alteredHM = signature();
|
||||
const sentinel = document.createElement('span');
|
||||
sentinel.textContent = 'Host style isolation sentinel';
|
||||
sentinel.style.color = 'rgb(55, 66, 77)'; sentinel.style.backgroundColor = 'rgb(88, 99, 110)';
|
||||
document.body.append(sentinel);
|
||||
const sentinelBefore = { color: getComputedStyle(sentinel).color, background: getComputedStyle(sentinel).backgroundColor };
|
||||
// Public inputs intentionally remain undeclared; defaults resolve on the
|
||||
// shadow shell so a host input can inherit without being masked by :host.
|
||||
const token = '--hm-orgchart-bg-surface';
|
||||
host.style.setProperty(token, 'rgb(7, 23, 41)');
|
||||
const scopedOverride = signature();
|
||||
const sentinelAfter = { color: getComputedStyle(sentinel).color, background: getComputedStyle(sentinel).backgroundColor };
|
||||
if (token) host.style.removeProperty(token);
|
||||
for (const property of ['--hm-sem-bg-surface', '--hm-sem-fg-primary', '--hm-radius-container', '--hm-font-family-base']) host.style.removeProperty(property);
|
||||
sentinel.remove();
|
||||
await Promise.all(documentLinks.map(link => new Promise(resolve => {
|
||||
link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', resolve, { once: true }); document.head.append(link);
|
||||
})));
|
||||
return { initial, withoutTheme, alteredHM, scopedOverride, token, sentinelBefore, sentinelAfter, restored: signature(), removedDocumentStyles: documentLinks.length };
|
||||
});
|
||||
metrics.push({ transport, stage: 'private-style-isolation', ...evidence });
|
||||
check(same(evidence.initial, evidence.withoutTheme), `${transport}: Core style is unchanged without host HM Theme CSS`, evidence);
|
||||
check(same(evidence.withoutTheme, evidence.alteredHM), `${transport}: unrelated host HM color/radius/font variables cannot change private OrgChart style`, evidence);
|
||||
check(evidence.token && evidence.scopedOverride.background === 'rgb(7, 23, 41)' && same(evidence.sentinelBefore, evidence.sentinelAfter), `${transport}: scoped OrgChart surface override affects its instance only`, evidence);
|
||||
check(same(evidence.initial, evidence.restored), `${transport}: removing the scoped override restores standalone defaults`, evidence);
|
||||
}
|
||||
|
||||
async function standaloneFactories(page) {
|
||||
const evidence = await page.evaluate(async data => {
|
||||
const api = window.OrgChartP1.api;
|
||||
const stylesheetUrls = window.OrgChartP1.stylesheetUrls;
|
||||
const existing = document.querySelector('.hm-orgchart-host');
|
||||
const originalBackground = getComputedStyle(existing.shadowRoot.querySelector('.hm-orgchart-employee[data-card-kind=employee]')).backgroundColor;
|
||||
const results = [];
|
||||
for (const [factory, selector] of [['createPositionSummary', '.hm-orgchart-position-summary'], ['createAffiliationSummary', '.hm-orgchart-affiliation-summary'], ['createStatusSummary', '.hm-orgchart-status-summary']]) {
|
||||
const container = document.createElement('div'); container.style.maxWidth = '760px'; document.body.append(container);
|
||||
const events = [];
|
||||
const handle = api[factory]({ container, dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrl: stylesheetUrls.core, onStateChange: state => events.push(state) });
|
||||
await handle.mount();
|
||||
const shadow = container.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||||
const mounted = { state: handle.getState().status, total: handle.getState().summary?.raw.total,
|
||||
modules: shadow.querySelectorAll(selector).length, nodes: shadow.querySelectorAll('.node').length,
|
||||
foreign: shadow.querySelectorAll('.hm-orgchart-composition, .hm-orgchart-heading, .hm-orgchart-hub, .hm-orgchart-viewport').length,
|
||||
links: [...shadow.querySelectorAll('link')].map(link => link.getAttribute('href')),
|
||||
headings: shadow.textContent,
|
||||
};
|
||||
const zero = structuredClone(data); zero.metadata.dataVersion = `${factory}-zero`;
|
||||
zero.employees = []; for (const org of zero.organizations) org.leaderId = null;
|
||||
await handle.update(zero);
|
||||
const currentShadow = container.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||||
const updated = { state: handle.getState().status, version: handle.getState().dataVersion, total: handle.getState().summary?.raw.total,
|
||||
employees: currentShadow.querySelectorAll('[data-employee-id]').length,
|
||||
affiliations: [...currentShadow.querySelectorAll('.hm-orgchart-affiliation-count')].map(element => element.textContent),
|
||||
emptyGroups: [...currentShadow.querySelectorAll('.hm-orgchart-content-empty')].map(element => element.textContent),
|
||||
};
|
||||
const sibling = document.createElement('p'); sibling.textContent = 'Host-owned sibling'; container.append(sibling);
|
||||
handle.destroy(); handle.destroy();
|
||||
results.push({ factory, mounted, updated, eventStatuses: events.map(state => state.status),
|
||||
destroyed: handle.getState().status, remaining: container.children.length, siblingPreserved: sibling.isConnected });
|
||||
container.remove();
|
||||
}
|
||||
const container = document.createElement('div'); document.body.append(container);
|
||||
const tree = api.createOrgChartTree({ container, dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrls });
|
||||
await tree.mount();
|
||||
const treeShadow = container.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
const onlyTree = { ready: tree.getState().status, nodes: treeShadow.querySelectorAll('.node').length,
|
||||
employeeIDs: [...new Set([...treeShadow.querySelectorAll('[data-employee-id]')].map(element => element.dataset.employeeId))].sort(),
|
||||
forbidden: treeShadow.querySelectorAll('.hm-orgchart-heading,.hm-orgchart-hub,.hm-orgchart-composition,.hm-orgchart-position-summary,.hm-orgchart-affiliation-summary,.hm-orgchart-status-summary').length,
|
||||
viewport: treeShadow.querySelectorAll('.hm-orgchart-viewport').length,
|
||||
};
|
||||
const moved = structuredClone(data); moved.metadata.dataVersion = 'standalone-tree-updated';
|
||||
moved.employees.find(employee => employee.id === 'mock-employee-01').organizationId = 'solution-sales-1';
|
||||
await tree.update(moved);
|
||||
const nextShadow = container.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
onlyTree.updatedVersion = tree.getState().dataVersion;
|
||||
onlyTree.employeeMoved = !![...nextShadow.querySelectorAll('.node')].find(node => node.dataset.organizationId === 'solution-sales-1')?.querySelector('[data-employee-id="mock-employee-01"]');
|
||||
tree.destroy(); onlyTree.destroyed = container.children.length === 0; container.remove();
|
||||
const existingBackground = getComputedStyle(existing.shadowRoot.querySelector('.hm-orgchart-employee[data-card-kind=employee]')).backgroundColor;
|
||||
return { summaries: results, tree: onlyTree, originalBackground, existingBackground };
|
||||
}, fixture);
|
||||
metrics.push({ stage: 'standalone-public-factories', ...evidence });
|
||||
for (const item of evidence.summaries) {
|
||||
check(item.mounted.state === 'ready' && item.mounted.total === 41 && item.mounted.modules === 1 && item.mounted.nodes === 0 && item.mounted.foreign === 0 && item.mounted.links.length === 1, `${item.factory}: public factory mounts only its own summary without engine, heading or composition`, item.mounted);
|
||||
check(item.updated.state === 'ready' && item.updated.version === `${item.factory}-zero` && item.updated.total === 0 && item.updated.employees === 0
|
||||
&& (item.factory !== 'createAffiliationSummary' || item.updated.affiliations.length === 7 && item.updated.affiliations.every(text => text === '0명'))
|
||||
&& (item.factory !== 'createStatusSummary' || item.updated.emptyGroups.length === 2), `${item.factory}: update refreshes shared Raw Count and removes stale cards`, item.updated);
|
||||
check(item.destroyed === 'destroyed' && item.remaining === 1 && item.siblingPreserved, `${item.factory}: destroy releases only owned auxiliary DOM and preserves host siblings`, item);
|
||||
}
|
||||
check(evidence.tree.ready === 'ready' && evidence.tree.nodes === 18 && evidence.tree.viewport === 1 && evidence.tree.forbidden === 0
|
||||
&& same(evidence.tree.employeeIDs, fixture.employees.map(employee => employee.id).sort()), 'createOrgChartTree: public standalone tree preserves all source employees with no heading/hub/summary composition', evidence.tree);
|
||||
check(evidence.tree.updatedVersion === 'standalone-tree-updated' && evidence.tree.employeeMoved && evidence.tree.destroyed, 'createOrgChartTree: standalone update and destroy retain engine lifecycle', evidence.tree);
|
||||
check(evidence.originalBackground === evidence.existingBackground, 'standalone factories do not alter the existing full-composition instance');
|
||||
}
|
||||
|
||||
const expectedOldIds = ['basic-organization', 'organization-hierarchy', 'employee-cards', 'organization-summary', 'json-data-update', 'loading-empty-error', 'responsive-container', 'multiple-instances'].map(id => `orgchart-content--${id}`);
|
||||
const canonicalStoryIds = ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition'].map(id => `orgchart--${id}`);
|
||||
const internalStoryIds = [
|
||||
...['business-management', 'business-planning', 'talent-growth', 'integrated-system', 'design-planning', 'solution-sales', 'top-leader', 'organization-leader', 'employee'].map(id => `orgchart-components-cards--${id}`),
|
||||
...['position-summary', 'affiliation-summary', 'status-summary'].map(id => `orgchart-components-summaries--${id}`),
|
||||
];
|
||||
// Canonical entries cover every component once; only the three distinct employee
|
||||
// kinds additionally need their internal preview. Registry preservation is static.
|
||||
const renderedStoryIds = [...canonicalStoryIds, ...internalStoryIds.slice(6, 9)];
|
||||
const measuredGradients = [
|
||||
['#194d82', '#1d395c', 'rgb(25, 77, 130)', 'rgb(29, 57, 92)'],
|
||||
['#2164a8', '#254975', 'rgb(33, 100, 168)', 'rgb(37, 73, 117)'],
|
||||
['#0f6ace', '#044a94', 'rgb(15, 106, 206)', 'rgb(4, 74, 148)'],
|
||||
['#8c67b6', '#5d3494', 'rgb(140, 103, 182)', 'rgb(93, 52, 148)'],
|
||||
['#09a7a2', '#00847f', 'rgb(9, 167, 162)', 'rgb(0, 132, 127)'],
|
||||
['#5a963c', '#3f7629', 'rgb(90, 150, 60)', 'rgb(63, 118, 41)'],
|
||||
];
|
||||
|
||||
/** A Story metadata fix only invalidates its console checks. Preserve the full
|
||||
* browser.json run and recheck those five iframes plus the newly requested
|
||||
* actual manager sidebar, without repeating Core/React/summary/width checks. */
|
||||
async function recheckStorybookConsole() {
|
||||
const retryResults = [], retryErrors = [], rendered = [];
|
||||
const checkRetry = (condition, name, detail) => retryResults.push({ name, status: condition ? 'PASS' : 'FAIL', detail });
|
||||
const server = await serve(path.join(root, 'storybook-static'));
|
||||
let retryBrowser;
|
||||
let sidebar;
|
||||
try {
|
||||
retryBrowser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
|
||||
const context = await retryBrowser.newContext({ viewport: { width: 1440, height: 1080 } });
|
||||
const iframe = await context.newPage();
|
||||
iframe.on('pageerror', error => retryErrors.push({ surface: 'iframe', type: 'pageerror', message: error.message }));
|
||||
iframe.on('console', message => { if (message.type() === 'error') retryErrors.push({ surface: 'iframe', type: 'console', message: message.text() }); });
|
||||
for (const id of canonicalStoryIds) {
|
||||
await iframe.goto(`${server.origin}/iframe.html?id=${id}&viewMode=story`);
|
||||
await iframe.waitForFunction(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
|
||||
return shadow?.querySelector('[data-component]') && [...shadow.querySelectorAll('link')].every(link => link.sheet);
|
||||
}, undefined, { timeout: 20000 });
|
||||
rendered.push(id);
|
||||
}
|
||||
checkRetry(retryErrors.filter(error => error.surface === 'iframe').length === 0 && rendered.length === 5, 'five corrected canonical Story iframes emit no console/runtime errors', { rendered, errors: retryErrors.filter(error => error.surface === 'iframe') });
|
||||
const manager = await context.newPage();
|
||||
manager.on('pageerror', error => retryErrors.push({ surface: 'manager', type: 'pageerror', message: error.message }));
|
||||
manager.on('console', message => { if (message.type() === 'error') retryErrors.push({ surface: 'manager', type: 'console', message: message.text() }); });
|
||||
await manager.goto(`${server.origin}/?path=/story/orgchart--full-composition`);
|
||||
await manager.waitForSelector('#storybook-explorer-tree [data-item-id="orgchart--full-composition"]', { timeout: 20000 });
|
||||
sidebar = await manager.evaluate(() => [...document.querySelectorAll('#storybook-explorer-tree [data-item-id]')].filter(row => row.dataset.itemId.startsWith('orgchart') && row.getBoundingClientRect().height > 0).map(row => ({ id: row.dataset.itemId, label: row.textContent.trim() })));
|
||||
const visibleStories = sidebar.filter(row => row.id.startsWith('orgchart--')).map(row => row.id).sort();
|
||||
checkRetry(same(visibleStories, [...canonicalStoryIds].sort()), 'actual Storybook sidebar exposes exactly the approved five OrgChart Story entries', sidebar);
|
||||
checkRetry(sidebar.every(row => !row.id.startsWith('orgchart-content') && !row.id.startsWith('orgchart-components')),
|
||||
'actual default sidebar hides the original/internal reference groups while preserving their direct routes', sidebar);
|
||||
await manager.screenshot({ path: path.join(output, 'storybook-sidebar.png'), fullPage: true });
|
||||
checkRetry(retryErrors.filter(error => error.surface === 'manager').length === 0, 'actual Storybook manager and its preview emit no console/runtime errors', retryErrors.filter(error => error.surface === 'manager'));
|
||||
} catch (error) {
|
||||
retryResults.push({ name: 'selected Storybook console/sidebar recheck completion', status: 'FAIL', detail: error.stack });
|
||||
} finally {
|
||||
const report = { browser: retryBrowser?.version() ?? null, mode: 'storybook-console-only', preservedInitialReport: path.join(output, 'browser.json'), results: retryResults, errors: retryErrors, rendered, sidebar,
|
||||
limits: ['196 previously successful Core/React/component checks were not repeated', 'This run only rechecks corrected Story metadata and newly verifies the default manager sidebar'] };
|
||||
const reportPath = path.join(output, 'storybook-console-recheck.json');
|
||||
await writeFile(reportPath, JSON.stringify(report, null, 2));
|
||||
if (retryBrowser) await retryBrowser.close();
|
||||
await new Promise(resolve => server.server.close(resolve));
|
||||
const failed = retryResults.filter(result => result.status === 'FAIL');
|
||||
if (failed.length) process.exitCode = 1;
|
||||
console.log(JSON.stringify({ passed: retryResults.filter(result => result.status === 'PASS').length, failed, report: reportPath, sidebarScreenshot: path.join(output, 'storybook-sidebar.png') }, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv.includes('--storybook-console-only')) {
|
||||
await recheckStorybookConsole();
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
|
||||
let browser;
|
||||
const servers = [];
|
||||
try {
|
||||
const productionStyles = await readFile(path.join(root, 'packages/org-chart/dist/style.css'), 'utf8');
|
||||
const foreignHMVariables = [...productionStyles.matchAll(/var\(\s*(--hm-(?!orgchart-)[a-z0-9-]+)/g)].map(match => match[1]);
|
||||
check(foreignHMVariables.length === 0, 'shipped Core stylesheet references only the private OrgChart namespace rather than host HM Theme variables', [...new Set(foreignHMVariables)]);
|
||||
const vanilla = await serve(path.join(root, 'examples/org-chart/dist')); servers.push(vanilla);
|
||||
const react = await serve(path.join(root, 'examples/org-chart-react/dist')); servers.push(react);
|
||||
const storybook = await serve(path.join(root, 'storybook-static')); servers.push(storybook);
|
||||
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1080 } });
|
||||
const pages = new Map();
|
||||
for (const [transport, origin] of [['vanilla', vanilla.origin], ['react', react.origin]]) {
|
||||
const page = await context.newPage(); pages.set(transport, page); captureErrors(page, transport);
|
||||
await page.goto(`${origin}/p1.html`);
|
||||
await ready(page, transport, fixture.metadata.dataVersion);
|
||||
const initial = await snapshot(page); snapshots.push({ transport, stage: 'initial', value: initial });
|
||||
check(initial.nodes.length === 18 && expectedCounts(fixture).total === 41, `${transport}: P1 fixture contains 18 real-name organizations and 41 virtual employee IDs`);
|
||||
check(initial.title === fixture.presentation.title && initial.hub === fixture.presentation.hubText, `${transport}: heading and independent hub come from JSON`);
|
||||
check(initial.nodes.every(node => fixture.organizations.find(org => org.id === node.id)?.parentId === node.parentId), `${transport}: rendered hierarchy retains every original parentId`, initial.nodes.map(({ id, parentId }) => ({ id, parentId })));
|
||||
const topEmployee = fixture.organizations.find(org => org.parentId === null)?.leaderId;
|
||||
check(initial.topLeaders.length === 1 && initial.topLeaders[0].id === topEmployee && initial.topLeaders[0].kind === 'topLeader'
|
||||
&& initial.nodes.every(node => node.employees.every(employee => employee.id !== topEmployee)), `${transport}: top leader is independent and is not duplicated in the tree`);
|
||||
check(initial.nodes.filter(node => node.depth === 1).length === 6 && initial.components.includes('OrgChartTree'), `${transport}: six primary organization groups preserve the engine tree boundary`);
|
||||
check(['PositionSummary', 'AffiliationSummary', 'StatusSummary'].every(name => initial.components.filter(value => value === name).length === 1), `${transport}: three independent summary modules are composed once`);
|
||||
checkCounts(initial, fixture, `${transport}: initial`);
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
await page.setViewportSize({ width, height: 1080 });
|
||||
const measurement = await measure(page); metrics.push({ transport, stage: 'initial', width, ...measurement });
|
||||
check(measurement.document.scrollWidth <= measurement.document.clientWidth + 1, `${transport}: ${width}px document has no horizontal overflow`, measurement.document);
|
||||
check(measurement.viewport.scrollWidth > measurement.viewport.clientWidth && measurement.viewport.overflowX === 'auto', `${transport}: ${width}px wide tree uses internal scrolling`, measurement.viewport);
|
||||
check(measurement.textFits && !measurement.nodeOverlap && measurement.ids.every(item => item.whiteSpace === 'nowrap' && item.title === item.text && item.scrollHeight <= item.height + 1), `${transport}: ${width}px text bounds, node separation and one-line IDs`, { textIssues: measurement.textIssues, overlap: measurement.nodeOverlap, ids: measurement.ids });
|
||||
check(measurement.cssLoaded && measurement.tokens.length >= 10 && measurement.tokens.every(item => item.value) && measurement.font && measurement.background !== 'rgba(0, 0, 0, 0)' && measurement.radius !== '0px', `${transport}: ${width}px independent OrgChart defaults and loaded Shadow styles`, { tokens: measurement.tokens, font: measurement.font, background: measurement.background, radius: measurement.radius });
|
||||
check(measurement.logos.length === 7 && measurement.logos.every(item => item.available === 'true' && item.loaded && item.src.startsWith('data:image/')), `${transport}: ${width}px all seven acquired affiliation logos load`, measurement.logos.map(({ id, available, loaded }) => ({ id, available, loaded })));
|
||||
check(measurement.portraitsFallback && measurement.navigationCount === 0, `${transport}: ${width}px virtual portrait fallback and content-only UI`);
|
||||
await page.screenshot({ path: path.join(output, `${transport}-${width}.png`), fullPage: true });
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1080 });
|
||||
await styleIsolation(page, transport);
|
||||
const toggle = page.locator('.hm-orgchart-toggle').first();
|
||||
await toggle.focus(); await toggle.press('Enter');
|
||||
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding'));
|
||||
check(await toggle.getAttribute('aria-expanded') === 'false' && await toggle.evaluate(element => element.getRootNode().activeElement === element), `${transport}: keyboard Enter collapses hierarchy and keeps focus`);
|
||||
await toggle.press('Space');
|
||||
await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding'));
|
||||
check(await toggle.getAttribute('aria-expanded') === 'true', `${transport}: keyboard Space expands hierarchy`);
|
||||
}
|
||||
check(same(snapshots.find(item => item.transport === 'vanilla').value, snapshots.find(item => item.transport === 'react').value), 'HTML and React initial Core DOM and automatic summary snapshots are identical');
|
||||
|
||||
const changed = dataWith(fixture, 'p1-browser-json-v2');
|
||||
const moved = changed.employees.find(employee => employee.id === 'mock-employee-01');
|
||||
moved.organizationId = 'solution-sales-1'; moved.affiliationId = 'halla'; moved.workStatus = 'onLeave';
|
||||
moved.name = '가상직원 긴 이름과 콘텐츠 줄바꿈 검증 01'; moved.position = '가상 장기 프로젝트 연구 및 운영 담당 수석';
|
||||
moved.jobTitle = '가상 프로젝트 담당';
|
||||
changed.presentation.title = 'JSON 갱신 · 가상 직원 이동 및 상태 변경';
|
||||
for (const [transport, page] of pages) {
|
||||
await page.locator('.hm-orgchart-summary-scroll').focus();
|
||||
await setData(page, transport, changed);
|
||||
check(await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.classList.contains('hm-orgchart-viewport')), `${transport}: summary keyboard focus returns to stable tree after composition regeneration`);
|
||||
const current = await snapshot(page); snapshots.push({ transport, stage: 'changed', value: current });
|
||||
check(current.title === changed.presentation.title && current.nodes.find(node => node.id === moved.organizationId)?.employees.some(employee => employee.id === moved.id && employee.name === moved.name && employee.position === moved.position), `${transport}: changed JSON moves and updates the same employee card`);
|
||||
checkCounts(current, changed, `${transport}: changed JSON`);
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
await page.setViewportSize({ width, height: 1080 });
|
||||
const measurement = await measure(page); metrics.push({ transport, stage: 'long-text', width, ...measurement });
|
||||
check(measurement.textFits && measurement.document.scrollWidth <= width + 1, `${transport}: ${width}px long employee name, position and job title fit with contained table scrolling`, { textIssues: measurement.textIssues, document: measurement.document, summary: measurement.summary });
|
||||
}
|
||||
check(current.statuses.find(group => group.status === 'onLeave').employees.some(employee => employee.id === moved.id) && !current.statuses.find(group => group.status === 'dispatched').employees.some(employee => employee.id === moved.id), `${transport}: state groups update without stale employee cards`);
|
||||
// Flags select independent components rather than alter the employee source or counts.
|
||||
const cases = [
|
||||
['hide-position', { showSummary: false }, { position: false, affiliations: true, dispatched: true, onLeave: true }],
|
||||
['hide-affiliation', { showAffiliations: false }, { position: true, affiliations: false, dispatched: true, onLeave: true }],
|
||||
['hide-dispatched', { showDispatched: false }, { position: true, affiliations: true, dispatched: false, onLeave: true }],
|
||||
['hide-onleave', { showOnLeave: false }, { position: true, affiliations: true, dispatched: true, onLeave: false }],
|
||||
['hide-both-status', { showDispatched: false, showOnLeave: false }, { position: true, affiliations: true, dispatched: false, onLeave: false }],
|
||||
];
|
||||
for (const [name, flags, expected] of cases) {
|
||||
const data = dataWith(changed, `${transport}-${name}`); Object.assign(data.presentation.groups, flags);
|
||||
await setData(page, transport, data);
|
||||
const current = await snapshot(page);
|
||||
check(!!current.positions === expected.position && !!current.affiliations.length === expected.affiliations
|
||||
&& current.statuses.some(group => group.status === 'dispatched') === expected.dispatched
|
||||
&& current.statuses.some(group => group.status === 'onLeave') === expected.onLeave
|
||||
&& current.components.includes('StatusSummary') === (expected.dispatched || expected.onLeave), `${transport}: ${name} changes only its independent component`, current.components);
|
||||
check(!current.positions || Number(current.positions.totals.at(-2)) === 41, `${transport}: ${name} cannot alter Raw Count`);
|
||||
}
|
||||
const zeroAffiliation = dataWith(changed, `${transport}-zero-affiliation`);
|
||||
for (const employee of zeroAffiliation.employees) if (employee.affiliationId === 'baron') employee.affiliationId = 'hanmac';
|
||||
await setData(page, transport, zeroAffiliation);
|
||||
check((await snapshot(page)).affiliations.find(item => item.id === 'baron')?.count === '0명', `${transport}: zero-count declared affiliations stay visible`);
|
||||
const zeroStatus = dataWith(changed, `${transport}-zero-status`);
|
||||
for (const employee of zeroStatus.employees) employee.workStatus = 'active';
|
||||
await setData(page, transport, zeroStatus);
|
||||
check((await snapshot(page)).statuses.every(group => group.employees.length === 0 && group.empty && group.heading.includes('0명')), `${transport}: enabled empty status groups have explicit empty content`);
|
||||
// A successful HTTP response containing non-image bytes triggers img.error,
|
||||
// avoiding expected network-console failures while testing the actual fallback.
|
||||
await page.route('**/p1-invalid-portrait.png', route => route.fulfill({ status: 200, contentType: 'image/png', body: 'not a png' }));
|
||||
const brokenImage = dataWith(changed, `${transport}-image-fallback`);
|
||||
brokenImage.employees.find(employee => employee.id === moved.id).profileImage = './p1-invalid-portrait.png';
|
||||
await setData(page, transport, brokenImage);
|
||||
const brokenCards = page.locator(`.hm-orgchart-employee[data-employee-id="${moved.id}"]`);
|
||||
for (let index = 0; index < await brokenCards.count(); index++) await brokenCards.nth(index).scrollIntoViewIfNeeded();
|
||||
await page.waitForFunction(id => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(card => card.dataset.employeeId === id).every(card => !card.querySelector('.hm-orgchart-avatar img')), moved.id);
|
||||
check(await page.evaluate(id => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(card => card.dataset.employeeId === id).every(card => card.querySelector('.hm-orgchart-avatar').textContent.trim()), moved.id), `${transport}: invalid profile images safely return to initials`);
|
||||
await page.unroute('**/p1-invalid-portrait.png');
|
||||
await setData(page, transport, dataWith(changed, 'p1-browser-final-changed'));
|
||||
snapshots.push({ transport, stage: 'restored-changed', value: await snapshot(page) });
|
||||
check(transport !== 'react' || await page.evaluate(() => window.OrgChartP1React.errors.length === 0), `${transport}: auxiliary regeneration has no lifecycle error`);
|
||||
}
|
||||
check(same(...snapshots.filter(item => item.stage === 'restored-changed').map(item => item.value)), 'HTML and React Core snapshots remain identical after JSON and independent visibility updates');
|
||||
|
||||
const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8'));
|
||||
const old = Object.values(index.entries).filter(entry => entry.title === 'OrgChart/Content' && entry.type === 'story');
|
||||
check(old.length === 8 && same(old.map(entry => entry.id).sort(), [...expectedOldIds].sort()) && old.every(entry => entry.tags.includes('internal')), 'all original eight Storybook entries remain registered as internal without re-running their baseline suite', old.map(entry => entry.id));
|
||||
check(internalStoryIds.every(id => index.entries[id]?.type === 'story' && index.entries[id].tags.includes('internal')), 'twelve isolated card/summary Stories are preserved as internal references', internalStoryIds);
|
||||
const visible = Object.values(index.entries).filter(entry => entry.title === 'OrgChart' && entry.type === 'story' && !entry.tags.includes('internal')).map(entry => entry.id).sort();
|
||||
check(same(visible, [...canonicalStoryIds].sort()), 'canonical OrgChart offers exactly the five approved user-visible entries', visible);
|
||||
const sb = await context.newPage(); captureErrors(sb, 'storybook');
|
||||
for (const id of renderedStoryIds) {
|
||||
if (!index.entries[id]) continue;
|
||||
await sb.goto(`${storybook.origin}/iframe.html?id=${id}&viewMode=story`);
|
||||
await sb.waitForFunction(() => {
|
||||
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host');
|
||||
const shadow = host?.shadowRoot;
|
||||
return shadow?.querySelector('[data-component]') && [...shadow.querySelectorAll('link')].every(link => link.sheet);
|
||||
}, undefined, { timeout: 20000 });
|
||||
await sb.waitForFunction(() => [...document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-affiliation-mark img')].every(image => image.complete));
|
||||
const component = await sb.evaluate(() => {
|
||||
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host'), shadow = host.shadowRoot;
|
||||
const organizationName = shadow.querySelector('.hm-orgchart-organization-name');
|
||||
const node = shadow.querySelector('.node');
|
||||
return { components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component), nodeCount: shadow.querySelectorAll('.node').length,
|
||||
employees: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, kind: card.dataset.cardKind })),
|
||||
gradients: organizationName ? { background: getComputedStyle(organizationName).backgroundImage, variable: getComputedStyle(node).getPropertyValue('--hm-orgchart-group-background').trim() } : null,
|
||||
body: document.body.textContent, title: organizationName?.textContent,
|
||||
hasPosition: !!shadow.querySelector('.hm-orgchart-position-summary'), hasAffiliation: !!shadow.querySelector('.hm-orgchart-affiliation-summary'), hasStatus: !!shadow.querySelector('.hm-orgchart-status-summary'),
|
||||
linksLoaded: [...shadow.querySelectorAll('link')].every(link => link.sheet),
|
||||
};
|
||||
});
|
||||
snapshots.push({ transport: 'storybook', stage: id, value: component });
|
||||
check(component.linksLoaded, `${id}: real Core preview and Shadow CSS load`);
|
||||
if (id === 'orgchart--position-summary' || id === 'orgchart--affiliation-summary') {
|
||||
const kind = id.endsWith('position-summary') ? 'PositionSummary' : id.endsWith('affiliation-summary') ? 'AffiliationSummary' : 'StatusSummary';
|
||||
check(component.nodeCount === 0 && same(component.components, [kind])
|
||||
&& component.hasPosition === (kind === 'PositionSummary') && component.hasAffiliation === (kind === 'AffiliationSummary') && component.hasStatus === (kind === 'StatusSummary'), `${id}: standalone summary has exactly its Core module and no engine tree`, component.components);
|
||||
} else if (id.endsWith('full-composition')) {
|
||||
check(component.nodeCount === 18 && ['OrgChartTree', 'PositionSummary', 'AffiliationSummary', 'StatusSummary'].every(name => component.components.includes(name)), `${id}: composition contains shared tree plus all independent summaries`);
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
await sb.setViewportSize({ width, height: 1080 });
|
||||
const measurement = await measure(sb); metrics.push({ transport: 'storybook', stage: id, width, ...measurement });
|
||||
check(measurement.document.scrollWidth <= width + 1 && measurement.textFits && measurement.navigationCount === 0, `${id}: ${width}px content composition has no document overflow or clipped text`, { document: measurement.document, textIssues: measurement.textIssues });
|
||||
await sb.screenshot({ path: path.join(output, `${id}-${width}.png`), fullPage: true });
|
||||
}
|
||||
await sb.setViewportSize({ width: 1440, height: 1080 });
|
||||
await sb.getByRole('button', { name: 'PositionSummary 숨기기', exact: true }).click();
|
||||
await sb.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-position-summary'));
|
||||
check(await sb.locator('.hm-orgchart-affiliation-summary').count() === 1 && await sb.locator('.hm-orgchart-status-summary').count() === 1, `${id}: Story button independently hides PositionSummary`);
|
||||
await sb.getByRole('button', { name: '가상 JSON v2 반영', exact: true }).click();
|
||||
await sb.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('[data-work-status=onLeave] [data-employee-id]')].some(element => element.dataset.employeeId === 'mock-employee-01'));
|
||||
check(await sb.locator('.hm-orgchart-affiliation-summary .hm-orgchart-raw-total').textContent() === 'Raw Count · 전체 41명', `${id}: Story JSON update reuses 41 unique virtual employees`);
|
||||
} else if (id.endsWith('top-leader') || id.endsWith('organization-leader') || id.endsWith('--employee')) {
|
||||
const kind = id.endsWith('top-leader') ? 'topLeader' : id.endsWith('organization-leader') ? 'leader' : 'employee';
|
||||
check(component.nodeCount === 0 && component.employees.length === 1 && component.employees[0].kind === kind && same(component.components, ['EmployeeCard']), `${id}: standalone ${kind} uses one actual Core employee card`, component.employees);
|
||||
} else if (id === 'orgchart--department-colors') {
|
||||
await sb.waitForFunction(() => {
|
||||
const values = [...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-color-value')];
|
||||
return values.length === 30 && values.every(element => element.textContent.trim());
|
||||
});
|
||||
const colors = await sb.evaluate(() => [...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-color-preview[data-group-variant]')].map(row => ({
|
||||
variant: Number(row.dataset.groupVariant),
|
||||
text: row.textContent,
|
||||
background: getComputedStyle(row.querySelector('[data-color-variable$="-gradient"]')).backgroundImage,
|
||||
variable: getComputedStyle(row).getPropertyValue('--hm-orgchart-group-background').trim(),
|
||||
declarations: [...row.querySelectorAll('[data-color-variable]')].map(element => ({ variable: element.dataset.colorVariable, property: element.dataset.cssProperty, applied: getComputedStyle(element).getPropertyValue(element.dataset.cssProperty), valueText: element.parentElement.querySelector('.hm-orgchart-color-value')?.textContent })),
|
||||
card: (() => { const card = row.querySelector('.hm-orgchart-employee'); const style = getComputedStyle(card); return { background: style.backgroundColor, color: style.color, border: style.borderTopColor }; })(),
|
||||
})));
|
||||
check(colors.length === 6, `${id}: all six department color components are rendered`, colors);
|
||||
for (const color of colors) {
|
||||
const [start, end, rgbStart, rgbEnd] = measuredGradients[color.variant];
|
||||
check(color.background.includes('linear-gradient') && color.background.includes(rgbStart) && color.background.includes(rgbEnd)
|
||||
&& color.variable === `linear-gradient(180deg, ${start} 0%, ${end} 100%)`
|
||||
&& color.text.includes(start) && color.text.includes(end) && color.declarations.length === 5
|
||||
&& color.declarations.every(item => item.variable.startsWith('--hm-orgchart-') && item.valueText?.includes('#')), `${id}: variant ${color.variant} applies exact measured gradient and documents HEX/CSS variables`, color);
|
||||
check(color.declarations.find(item => item.variable.endsWith('-card-background')).applied === color.card.background
|
||||
&& color.declarations.find(item => item.variable.endsWith('-card-color')).applied === color.card.color
|
||||
&& color.declarations.find(item => item.variable.endsWith('-border')).applied === color.card.border, `${id}: variant ${color.variant} legend values match the actual card styles`, color.card);
|
||||
}
|
||||
} else if (id === 'orgchart--logo') {
|
||||
const logos = await sb.evaluate(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot;
|
||||
return { marks: [...shadow.querySelectorAll('.hm-orgchart-affiliation-mark')].map(mark => ({ id: mark.dataset.affiliationId, label: mark.getAttribute('aria-label'), src: mark.querySelector('img')?.getAttribute('src'), loaded: !!mark.querySelector('img')?.complete && mark.querySelector('img').naturalWidth > 0 })), text: shadow.textContent,
|
||||
counts: shadow.querySelectorAll('.hm-orgchart-affiliation-count, .hm-orgchart-raw-total, .hm-orgchart-position-table').length };
|
||||
});
|
||||
check(component.nodeCount === 0 && component.components.includes('Logo') && logos.marks.length === 7 && logos.marks.every(mark => mark.loaded && mark.src.startsWith('data:image/') && mark.label)
|
||||
&& fixture.affiliations.every(affiliation => logos.text.includes(affiliation.name)) && logos.counts === 0, `${id}: standalone Logo uses all seven named assets without statistics`, logos.marks.map(({ id, label, loaded }) => ({ id, label, loaded })));
|
||||
}
|
||||
}
|
||||
await standaloneFactories(pages.get('vanilla'));
|
||||
await pages.get('vanilla').evaluate(() => window.OrgChartP1.chart.destroy());
|
||||
check(await pages.get('vanilla').locator('.hm-orgchart-host').count() === 0, 'destroy releases composed tree and all auxiliary summary DOM');
|
||||
check(await pages.get('react').evaluate(() => window.OrgChartP1React.errors.length === 0), 'React wrapper lifecycle error boundary remains clear');
|
||||
check(errors.length === 0, 'targeted P1 Vanilla, React and canonical/employee Stories have no console/runtime errors', errors);
|
||||
} catch (error) {
|
||||
results.push({ name: 'P1 browser harness completion', status: 'FAIL', detail: error.stack });
|
||||
} finally {
|
||||
const report = { browser: browser?.version() ?? null, results, errors, metrics, snapshots,
|
||||
limits: ['Figma Dev Mode detailed properties NOT VERIFIED', 'Workdesk pixel parity NOT VERIFIED; gradient checks verify the recorded measured header sources only', 'Screen-reader announcements and heap/GC behavior NOT VERIFIED', 'Original 69/79 browser suites deliberately not repeated'] };
|
||||
await writeFile(path.join(output, 'browser.json'), JSON.stringify(report, null, 2));
|
||||
if (browser) await browser.close();
|
||||
for (const { server } of servers) await new Promise(resolve => server.close(resolve));
|
||||
const failed = results.filter(result => result.status === 'FAIL');
|
||||
if (failed.length) process.exitCode = 1;
|
||||
console.log(JSON.stringify({ passed: results.filter(result => result.status === 'PASS').length, failed, report: path.join(output, 'browser.json') }, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// P2-only browser evidence. Existing v0/P1 expectations and reports are retained.
|
||||
// ESM HTML and React use HTTP; the classic HTML is opened directly with file://.
|
||||
// No Chrome file-access flag is used, so classic portability is tested honestly.
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const output = path.join(root, 'test-results/org-chart-p2');
|
||||
await mkdir(output, { recursive: true });
|
||||
const fixturePath = path.join(root, 'examples/org-chart/data-p1.json');
|
||||
const fixtureBytes = await readFile(fixturePath);
|
||||
const fixtureHash = createHash('sha256').update(fixtureBytes).digest('hex');
|
||||
const fixture = JSON.parse(fixtureBytes.toString('utf8'));
|
||||
const bundledPlaywright = 'C:\\Users\\user\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\node\\node_modules\\playwright\\index.mjs';
|
||||
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE ?? bundledPlaywright).href);
|
||||
const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }];
|
||||
const expectedArrows = [[194.15, 311.75, 66.25, 19.8], [430.4, 311.75, 66.25, 19.8], [666.65, 302, 183.75, 42], [1044, 302, 258.92, 42], [1520.55, 305, 135.35, 36]];
|
||||
const results = [], measurements = [], errors = [], screenshots = [], servers = [];
|
||||
const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
|
||||
const near = (actual, expected, tolerance = 0.2) => Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance;
|
||||
const spacedKorean = text => text.replace(/([가-힣])(?=[가-힣])/g, '$1 ');
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' };
|
||||
async function serve(directory) {
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://127.0.0.1');
|
||||
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
|
||||
const relative = path.relative(directory, file);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
|
||||
response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream');
|
||||
response.end(await readFile(file));
|
||||
} catch { response.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
servers.push(server);
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
function captureErrors(page, route) {
|
||||
page.on('pageerror', error => errors.push({ route, type: 'pageerror', message: error.message }));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push({ route, type: 'console', message: message.text() }); });
|
||||
}
|
||||
async function ready(page, mode = 'readOnly') {
|
||||
await page.waitForFunction(expectedMode => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
|
||||
return shadow?.querySelector(`.hm-orgchart-composition[data-display-mode="${expectedMode}"] [data-card-asset]`)
|
||||
&& !shadow.querySelector('.hm-orgchart-state')?.textContent
|
||||
&& [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet);
|
||||
}, mode, { timeout: 20000 });
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
await Promise.all([...shadow.querySelectorAll('img')].map(image => image.decode().catch(() => {})));
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
}
|
||||
async function measure(page) {
|
||||
return page.evaluate(async () => {
|
||||
const host = document.querySelector('.hm-orgchart-host');
|
||||
const shadow = host.shadowRoot;
|
||||
const composition = shadow.querySelector('.hm-orgchart-composition');
|
||||
const stage = shadow.querySelector('.hm-orgchart-stage');
|
||||
const origin = stage.getBoundingClientRect();
|
||||
// dataset.scale is rounded to four digits; measured transform is required
|
||||
// to recover the small cards accurately at the 390px viewport.
|
||||
const scale = origin.width / stage.offsetWidth;
|
||||
const rect = element => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return { x: (box.left - origin.left) / scale, y: (box.top - origin.top) / scale, width: box.width / scale, height: box.height / scale };
|
||||
};
|
||||
const dimensions = element => element ? { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY } : null;
|
||||
const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
|
||||
const overlaps = (a, b) => Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.75 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.75;
|
||||
const fingerprint = source => {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < source.length; index++) hash = Math.imul(hash ^ source.charCodeAt(index), 16777619);
|
||||
return (hash >>> 0).toString(16);
|
||||
};
|
||||
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee[data-card-asset]')].map(card => {
|
||||
const node = card.closest('.node[data-organization-id]');
|
||||
const background = card.querySelector('.hm-orgchart-card-background');
|
||||
const css = getComputedStyle(card);
|
||||
const personName = card.querySelector('.hm-orgchart-person-name');
|
||||
const cardBox = rect(card), avatarBox = rect(card.querySelector('.hm-orgchart-avatar'));
|
||||
const logoBox = card.querySelector('.hm-orgchart-affiliation-mark') ? rect(card.querySelector('.hm-orgchart-affiliation-mark')) : null;
|
||||
const contentIssues = [], textBoxes = [], fontRangeOverlaps = [];
|
||||
const personText = card.querySelector('.hm-orgchart-person-text');
|
||||
const positionLine = personText?.firstElementChild;
|
||||
const lineBoxes = { position: positionLine ? rect(positionLine) : null, name: rect(personName) };
|
||||
const role = card.querySelector('.hm-orgchart-person-role');
|
||||
const badge = card.querySelector('.hm-orgchart-position-row .hm-orgchart-job-title');
|
||||
const horizontalRoleBadgeGap = badge ? rect(badge).x - rect(role).x - rect(role).width : null;
|
||||
if (lineBoxes.position && lineBoxes.position.y + lineBoxes.position.height > lineBoxes.name.y + 0.3) contentIssues.push('position/name: actual line-box overlap');
|
||||
if (horizontalRoleBadgeGap !== null && horizontalRoleBadgeGap < -0.3) contentIssues.push('role/badge: horizontal element-box overlap');
|
||||
const contained = box => box.x >= cardBox.x - 0.5 && box.y >= cardBox.y - 0.5 && box.x + box.width <= cardBox.x + cardBox.width + 0.5 && box.y + box.height <= cardBox.y + cardBox.height + 0.5;
|
||||
for (const element of card.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-job-title')) {
|
||||
if (element.clientWidth > 0 && element.scrollWidth > element.clientWidth + 1) contentIssues.push(`${element.className}: horizontal text overflow`);
|
||||
const range = document.createRange(); range.selectNodeContents(element);
|
||||
for (const box of range.getClientRects()) {
|
||||
const textBox = { x: (box.left - origin.left) / scale, y: (box.top - origin.top) / scale, width: box.width / scale, height: box.height / scale };
|
||||
if (!contained(textBox)) contentIssues.push(`${element.className}: text outside card`);
|
||||
if (overlaps(textBox, avatarBox)) contentIssues.push(`${element.className}: avatar overlap`);
|
||||
if (logoBox && overlaps(textBox, logoBox)) contentIssues.push(`${element.className}: logo overlap`);
|
||||
textBoxes.push({ className: element.className, box: textBox });
|
||||
}
|
||||
}
|
||||
// Range rectangles contain the font ascent/descent envelope, including
|
||||
// unpainted space. Noto Sans KR can make adjacent 0-gap line envelopes
|
||||
// intersect despite distinct element line boxes and non-overlapping ink.
|
||||
// Retain these metrics for visual review; never classify them as glyph
|
||||
// collisions or silently claim that screenshot pixels were verified.
|
||||
for (const [index, a] of textBoxes.entries()) for (const b of textBoxes.slice(index + 1)) if (a.className !== b.className && overlaps(a.box, b.box)) fontRangeOverlaps.push({ a: a.className, b: b.className, horizontal: Math.min(a.box.x + a.box.width, b.box.x + b.box.width) - Math.max(a.box.x, b.box.x), vertical: Math.min(a.box.y + a.box.height, b.box.y + b.box.height) - Math.max(a.box.y, b.box.y) });
|
||||
if (!contained(avatarBox)) contentIssues.push('avatar outside card');
|
||||
if (logoBox && !contained(logoBox)) contentIssues.push('logo outside card');
|
||||
return {
|
||||
employeeId: card.dataset.employeeId, organizationId: node?.dataset.organizationId ?? null,
|
||||
depth: node ? Number(node.dataset.organizationDepth ?? node.dataset.depth) : null,
|
||||
kind: card.dataset.cardKind, asset: card.dataset.cardAsset, size: card.dataset.cardAssetSize,
|
||||
topSlot: !!card.closest('.hm-orgchart-top-leaders'), box: rect(card),
|
||||
naturalWidth: background?.naturalWidth ?? 0, naturalHeight: background?.naturalHeight ?? 0,
|
||||
embedded: !!background?.src.startsWith('data:image/svg+xml'), loaded: !!background?.complete && background.naturalWidth > 0,
|
||||
name: text(personName), role: text(card.querySelector('.hm-orgchart-person-role')), badge: text(card.querySelector('.hm-orgchart-job-title')),
|
||||
nameAccessible: personName.getAttribute('aria-label'), roleAccessible: card.querySelector('.hm-orgchart-person-role')?.getAttribute('aria-label'), badgeAccessible: card.querySelector('.hm-orgchart-job-title')?.getAttribute('aria-label'),
|
||||
nameStyle: { fontSize: getComputedStyle(personName).fontSize, fontWeight: getComputedStyle(personName).fontWeight, textOverflow: getComputedStyle(personName).textOverflow },
|
||||
contentIssues, textBoxes, lineBoxes, horizontalRoleBadgeGap, fontRangeOverlaps,
|
||||
radius: css.borderRadius, border: css.borderWidth, fill: css.backgroundColor,
|
||||
};
|
||||
});
|
||||
const headers = [...shadow.querySelectorAll('.node[data-organization-id] .hm-orgchart-organization-name')].map(header => {
|
||||
const node = header.closest('.node');
|
||||
const card = node.querySelector('.hm-orgchart-employee');
|
||||
const box = rect(header);
|
||||
const wrapperCss = getComputedStyle(header.parentElement);
|
||||
return { organizationId: node.dataset.organizationId, depth: Number(node.dataset.organizationDepth ?? node.dataset.depth), text: text(header), box,
|
||||
firstCardKind: card?.dataset.cardKind ?? null, headerCardGap: card ? rect(card).y - box.y - box.height : null,
|
||||
wrapperBorder: wrapperCss.borderWidth, wrapperFill: wrapperCss.backgroundColor, wrapperShadow: wrapperCss.boxShadow };
|
||||
});
|
||||
const overlay = shadow.querySelector('.hm-orgchart-workdesk-overlay');
|
||||
const arrows = await Promise.all([...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay image[data-arrow]')].map(async element => {
|
||||
const source = element.getAttribute('href') ?? element.getAttributeNS('http://www.w3.org/1999/xlink', 'href') ?? '';
|
||||
const image = new Image(); image.src = source;
|
||||
await image.decode().catch(() => {});
|
||||
return { id: element.dataset.arrow, x: Number(element.getAttribute('x')), y: Number(element.getAttribute('y')), width: Number(element.getAttribute('width')), height: Number(element.getAttribute('height')),
|
||||
box: rect(element), embedded: source.startsWith('data:image/png'), fingerprint: fingerprint(source), loaded: image.complete && image.naturalWidth > 0, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight,
|
||||
preserveAspectRatio: element.getAttribute('preserveAspectRatio') };
|
||||
}));
|
||||
const decorations = [...shadow.querySelectorAll('img[data-workdesk-asset]')].map(image => ({ asset: image.dataset.workdeskAsset, box: rect(image), embedded: image.src.startsWith('data:image/'), fingerprint: fingerprint(image.src), loaded: image.complete && image.naturalWidth > 0, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight }));
|
||||
const connectors = [...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay path[data-connector]')].map(element => ({ id: element.dataset.connector, path: element.getAttribute('d'), stroke: getComputedStyle(element).stroke, strokeWidth: getComputedStyle(element).strokeWidth, dasharray: getComputedStyle(element).strokeDasharray, fill: getComputedStyle(element).fill, box: rect(element) }));
|
||||
const physical = [...cards.map(card => ({ type: 'card', id: card.employeeId, box: card.box })), ...headers.map(header => ({ type: 'header', id: header.organizationId, box: header.box }))];
|
||||
const collisions = [];
|
||||
for (const [index, a] of physical.entries()) for (const b of physical.slice(index + 1)) if (overlaps(a.box, b.box)) collisions.push({ a: `${a.type}:${a.id}`, b: `${b.type}:${b.id}` });
|
||||
const arrowCollisions = arrows.flatMap(arrow => physical.filter(item => overlaps(arrow.box, item.box)).map(item => ({ arrow: arrow.id, item: `${item.type}:${item.id}` })));
|
||||
const summaries = [...shadow.querySelectorAll('.hm-orgchart-organization-totals, .hm-orgchart-affiliation-bar')].map(element => ({ className: element.className, box: rect(element), text: text(element) }));
|
||||
const summaryCollisions = summaries.flatMap(summary => physical.filter(item => overlaps(summary.box, item.box)).map(item => ({ summary: summary.className, item: `${item.type}:${item.id}` })));
|
||||
const pseudo = selector => [...shadow.querySelectorAll(selector)].map(element => {
|
||||
const before = getComputedStyle(element, '::before'), after = getComputedStyle(element, '::after');
|
||||
return { className: element.className, before: { content: before.content, display: before.display }, after: { content: after.content, display: after.display } };
|
||||
});
|
||||
return {
|
||||
scale, recordedScale: composition.dataset.scale, baseWidth: origin.width / scale, baseHeight: origin.height / scale,
|
||||
stageTransform: getComputedStyle(stage).transform, displayMode: composition.dataset.displayMode,
|
||||
document: dimensions(document.documentElement), viewport: dimensions(shadow.querySelector('.hm-orgchart-viewport')),
|
||||
cards, headers, arrows, decorations, connectors, overlayViewBox: overlay?.getAttribute('viewBox') ?? null,
|
||||
presentationNames: [...shadow.querySelectorAll('.hm-orgchart-employee .hm-orgchart-person-name')].map(element => ({ text: text(element), accessible: element.getAttribute('aria-label'), overflow: element.scrollWidth > element.clientWidth })),
|
||||
presentationPeople: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ employeeId: card.dataset.employeeId,
|
||||
role: text(card.querySelector('.hm-orgchart-person-role')), roleAccessible: card.querySelector('.hm-orgchart-person-role')?.getAttribute('aria-label'),
|
||||
badge: text(card.querySelector('.hm-orgchart-job-title')), badgeAccessible: card.querySelector('.hm-orgchart-job-title')?.getAttribute('aria-label') })),
|
||||
collisions, arrowCollisions, summaryCollisions, summaries,
|
||||
debugCount: shadow.querySelectorAll('.hm-orgchart-toggle, .hm-orgchart-kind, .hm-orgchart-domain-id, .hm-orgchart-node-count, .hm-orgchart-vacancy, .hm-orgchart-description, .hm-orgchart-policy').length,
|
||||
hostControls: document.querySelectorAll('.hm-orgchart-story-controls').length,
|
||||
largeMatrixCount: shadow.querySelectorAll('.hm-orgchart-position-table, .hm-orgchart-position-summary').length,
|
||||
totalsCount: shadow.querySelectorAll('.hm-orgchart-organization-totals').length,
|
||||
affiliationBarCount: shadow.querySelectorAll('.hm-orgchart-affiliation-bar').length,
|
||||
affiliationBarRows: [...shadow.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(element => rect(element).y),
|
||||
affiliationTotal: text(shadow.querySelector('.hm-orgchart-affiliation-bar-total dd')),
|
||||
statusGroups: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(element => ({ status: element.dataset.workStatus, text: text(element.querySelector('h3')), box: rect(element), border: getComputedStyle(element).borderWidth })),
|
||||
originalBranchPseudos: pseudo('.hm-orgchart-axis, .hm-orgchart-hub, .hm-orgchart-forest, .hm-orgchart-tree'),
|
||||
};
|
||||
});
|
||||
}
|
||||
function verify(value, label, viewport) {
|
||||
const top = value.cards.filter(card => card.kind === 'topLeader');
|
||||
const depth1 = value.cards.filter(card => card.kind === 'leader' && card.depth === 1);
|
||||
const deeper = value.cards.filter(card => card.kind === 'leader' && card.depth >= 2);
|
||||
const sizeMatches = (card, size, width, height) => card.size === size && near(card.box.width, width) && near(card.box.height, height) && card.naturalWidth === width && card.naturalHeight === height;
|
||||
check(top.length === 1 && top[0].topSlot && sizeMatches(top[0], 'representative', 146, 74), `${label}: representative alone uses 146×74 card`);
|
||||
check(depth1.length === 6 && depth1.every(card => sizeMatches(card, 'leader', 146, 68)), `${label}: all six actual Depth 1 leaders use 146×68 cards`, depth1.map(card => ({ id: card.employeeId, size: card.size, box: card.box })));
|
||||
check(deeper.length > 0 && deeper.every(card => sizeMatches(card, 'employee', 108, 52)), `${label}: every actual Depth ≥2 leader uses 108×52 card`, deeper.map(card => ({ id: card.employeeId, depth: card.depth, size: card.size, box: card.box })));
|
||||
check(deeper.every(card => spacedKorean(fixture.employees.find(employee => employee.id === card.employeeId)?.jobTitle ?? '') === card.badge && card.badge), `${label}: small leader cards preserve supplied job-title badges with requested Korean spacing`, deeper.map(card => ({ id: card.employeeId, badge: card.badge })));
|
||||
check(value.presentationNames.length > 0 && value.presentationNames.every(name => name.text === '홍 길 동' && name.accessible === '홍길동' && !name.overflow), `${label}: every displayed person name is 홍 길 동, fits, and retains an unspaced accessible name`, value.presentationNames);
|
||||
check(value.presentationPeople.every(card => {
|
||||
const employee = fixture.employees.find(employee => employee.id === card.employeeId);
|
||||
return employee && card.role === spacedKorean(employee.position) && card.roleAccessible === employee.position && card.badge === spacedKorean(employee.jobTitle ?? '') && (!employee.jobTitle || card.badgeAccessible === employee.jobTitle);
|
||||
}), `${label}: positions and job-title badges use one normal inter-Korean space while accessible labels retain original text`);
|
||||
check(value.cards.every(card => card.contentIssues.length === 0), `${label}: text stays within card/avatar/logo boundaries; actual line boxes and horizontal role/badge boxes do not overlap`, value.cards.filter(card => card.contentIssues.length).map(card => ({ id: card.employeeId, issues: card.contentIssues, textBoxes: card.textBoxes, lineBoxes: card.lineBoxes, horizontalRoleBadgeGap: card.horizontalRoleBadgeGap })));
|
||||
check(value.cards.every(card => card.loaded && card.embedded), `${label}: original embedded card SVG assets load`);
|
||||
const leaders = value.headers.filter(header => header.firstCardKind === 'leader');
|
||||
check(leaders.filter(header => header.depth === 1).length === 6 && leaders.every(header => near(header.headerCardGap, header.depth === 1 ? 8 : 10, 0.3)), `${label}: header-to-leader boundingRect gap is 8px at Depth 1 and 10px below`, leaders.map(header => ({ id: header.organizationId, depth: header.depth, gap: header.headerCardGap })));
|
||||
check(value.displayMode === 'readOnly' && value.debugCount === 0 && value.hostControls === 0, `${label}: read-only composition has no collapse/test/debug content`);
|
||||
check(value.headers.every(header => header.wrapperBorder === '0px' && header.wrapperFill === 'rgba(0, 0, 0, 0)' && header.wrapperShadow === 'none'), `${label}: organization groups retain unframed presentation`);
|
||||
check(value.totalsCount === 1 && value.affiliationBarCount === 1 && value.largeMatrixCount === 0, `${label}: compact organization totals and untitled affiliation bar are retained`);
|
||||
check(value.affiliationTotal === `${new Set(fixture.employees.map(employee => employee.id)).size}명` && Math.max(...value.affiliationBarRows) - Math.min(...value.affiliationBarRows) < 1, `${label}: affiliation counts retain one base-canvas row and fixture total`);
|
||||
check(value.statusGroups.length === 2 && value.statusGroups.every(group => group.border === '0px' && !group.text.includes('·')), `${label}: dispatched/on-leave groups retain compact presentation`);
|
||||
check(near(value.baseWidth, 1920, 0.3) && near(value.baseHeight, 914.5, 1) && value.scale > 0 && value.scale <= 1, `${label}: entire 1920×914.5 base stage scales uniformly`, { width: value.baseWidth, height: value.baseHeight, scale: value.scale });
|
||||
check(value.document.scrollWidth <= viewport.width + 1, `${label}: document has no horizontal overflow`, value.document);
|
||||
check(value.overlayViewBox?.replace(/\s+/g, ' ').trim() === '0 0 1920 914.5', `${label}: connectors and arrows share stage coordinates`);
|
||||
check(value.arrows.length === 5 && value.arrows.every((arrow, index) => [arrow.x, arrow.y, arrow.width, arrow.height].every((number, coordinate) => near(number, expectedArrows[index][coordinate], 0.05)) && arrow.preserveAspectRatio === 'none' && arrow.embedded && arrow.loaded), `${label}: five embedded collaboration arrows retain source placement`, value.arrows);
|
||||
check(value.decorations.length === 5 && value.decorations.every(asset => asset.embedded && asset.loaded), `${label}: five Workdesk decorations load without external URLs`, value.decorations);
|
||||
check(value.connectors.length === 5 && value.connectors.some(connector => /[Cc]/.test(connector.path ?? '')) && value.connectors.every(connector => connector.path && connector.fill === 'none' && connector.stroke === 'rgb(69, 99, 159)' && near(parseFloat(connector.strokeWidth), 0.893, 0.03) && connector.dasharray.split(/[ ,]+/).every(number => near(parseFloat(number), 1.79, 0.03))), `${label}: source overlay curves retain stroke, fill, width and dash pattern`, value.connectors);
|
||||
check(value.collisions.length === 0, `${label}: card/header rectangles do not overlap`, value.collisions);
|
||||
check(value.arrowCollisions.length === 0, `${label}: collaboration arrow rectangles do not intersect cards/headers`, value.arrowCollisions);
|
||||
check(value.summaryCollisions.length === 0, `${label}: compact summary rectangles do not intersect cards/headers`, value.summaryCollisions);
|
||||
}
|
||||
async function tooltip(page, transport) {
|
||||
const lowerOrganization = fixture.organizations.find(organization => organization.parentId && fixture.organizations.find(parent => parent.id === organization.parentId)?.parentId && organization.leaderId);
|
||||
const employeeId = lowerOrganization.leaderId;
|
||||
const fullName = '홍 길 동';
|
||||
const name = page.locator(`.hm-orgchart-employee[data-employee-id="${employeeId}"] .hm-orgchart-person-name`);
|
||||
await name.waitFor({ state: 'visible' });
|
||||
// All requested default names must fit. Induce a narrow name box locally to
|
||||
// exercise overflow behavior without altering fixture or presentation text.
|
||||
const originalInlineStyle = await name.getAttribute('style');
|
||||
await name.evaluate(element => { element.style.width = '8px'; element.style.maxWidth = '8px'; });
|
||||
await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"] .hm-orgchart-person-name`)?.dataset.nameOverflow === 'true', employeeId);
|
||||
const before = await page.evaluate(id => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
const name = shadow.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"] .hm-orgchart-person-name`);
|
||||
const tip = shadow.getElementById(name.getAttribute('aria-describedby'));
|
||||
return { fullText: name.textContent, tabIndex: name.tabIndex, ellipsis: getComputedStyle(name).textOverflow, tipHidden: tip.hidden, documentHeight: document.documentElement.scrollHeight, viewportHeight: shadow.querySelector('.hm-orgchart-viewport').scrollHeight };
|
||||
}, employeeId);
|
||||
check(before.fullText === fullName && before.tabIndex === 0 && before.ellipsis === 'ellipsis' && before.tipHidden, `${transport}: induced overflow preserves 홍 길 동 and exposes focusable ellipsis`, before);
|
||||
await name.hover();
|
||||
const tip = page.locator('.hm-orgchart-person-name-tooltip:not([hidden])');
|
||||
await tip.waitFor({ state: 'visible' });
|
||||
check(await tip.textContent() === fullName, `${transport}: hover exposes complete requested name in tooltip`);
|
||||
await tip.hover();
|
||||
check(await tip.isVisible(), `${transport}: tooltip remains hoverable`);
|
||||
await page.mouse.move(0, 0);
|
||||
await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('[role=tooltip]')].every(tip => tip.hidden));
|
||||
// The only overflowed name is in the normal tab order: use the keyboard,
|
||||
// rather than locator.focus(), to prove actual Tab activation.
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
let reached = false;
|
||||
for (let step = 0; step < 100 && !reached; step++) {
|
||||
await page.keyboard.press('Tab');
|
||||
reached = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.closest(`[data-employee-id="${id}"]`)?.dataset.employeeId === id, employeeId);
|
||||
}
|
||||
check(reached && await tip.isVisible(), `${transport}: keyboard Tab focus opens complete tooltip`);
|
||||
await page.keyboard.press('Escape');
|
||||
check(!(await tip.isVisible()), `${transport}: Escape dismisses tooltip while focus stays on name`);
|
||||
const after = await page.evaluate(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
return { documentHeight: document.documentElement.scrollHeight, viewportHeight: shadow.querySelector('.hm-orgchart-viewport').scrollHeight, activeClass: shadow.activeElement?.className };
|
||||
});
|
||||
check(before.documentHeight === after.documentHeight && before.viewportHeight === after.viewportHeight && after.activeClass === 'hm-orgchart-person-name', `${transport}: hidden tooltip does not inflate scroll extents or lose focus`, { before, after });
|
||||
await page.keyboard.press('Tab');
|
||||
check(!(await tip.isVisible()), `${transport}: moving focus keeps tooltip dismissed`);
|
||||
await name.evaluate((element, originalInlineStyle) => originalInlineStyle === null ? element.removeAttribute('style') : element.setAttribute('style', originalInlineStyle), originalInlineStyle);
|
||||
await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"] .hm-orgchart-person-name`)?.dataset.nameOverflow === 'false', employeeId);
|
||||
check(await name.getAttribute('tabindex') === null && await name.getAttribute('aria-describedby') === null, `${transport}: restoring the name box removes overflow-only focus and tooltip attributes`);
|
||||
}
|
||||
function sameGeometry(a, b) {
|
||||
if (a.cards.length !== b.cards.length || a.headers.length !== b.headers.length) return false;
|
||||
const boxesEqual = (first, second) => ['x', 'y', 'width', 'height'].every(key => near(first[key], second[key], 0.4));
|
||||
return a.cards.every((card, index) => {
|
||||
const other = b.cards[index];
|
||||
return ['employeeId', 'organizationId', 'depth', 'kind', 'asset', 'size', 'badge', 'name', 'role'].every(key => card[key] === other[key]) && boxesEqual(card.box, other.box);
|
||||
}) && a.headers.every((header, index) => header.organizationId === b.headers[index].organizationId && boxesEqual(header.box, b.headers[index].box))
|
||||
&& JSON.stringify(a.connectors.map(({ id, path }) => ({ id, path }))) === JSON.stringify(b.connectors.map(({ id, path }) => ({ id, path })))
|
||||
&& JSON.stringify(a.arrows.map(({ fingerprint, x, y, width, height }) => ({ fingerprint, x, y, width, height }))) === JSON.stringify(b.arrows.map(({ fingerprint, x, y, width, height }) => ({ fingerprint, x, y, width, height })));
|
||||
}
|
||||
let browser;
|
||||
try {
|
||||
const [htmlOrigin, reactOrigin] = await Promise.all([serve(path.join(root, 'examples/org-chart/dist')), serve(path.join(root, 'examples/org-chart-react/dist'))]);
|
||||
const storybookOrigin = process.env.ORGCHART_STORYBOOK_URL?.replace(/\/$/, '') ?? await serve(path.join(root, 'storybook-static'));
|
||||
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
|
||||
const context = await browser.newContext({ deviceScaleFactor: 1, reducedMotion: 'reduce' });
|
||||
const routes = [
|
||||
{ label: 'html', transport: 'html', url: `${htmlOrigin}/p1.html?mode=readOnly` },
|
||||
{ label: 'file-classic', transport: 'html', url: `${pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href}?mode=readOnly` },
|
||||
{ label: 'react', transport: 'react', url: `${reactOrigin}/p1.html?mode=readOnly` },
|
||||
{ label: 'storybook', transport: null, url: `${storybookOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story` },
|
||||
];
|
||||
for (const route of routes) {
|
||||
const page = await context.newPage(); captureErrors(page, route.label);
|
||||
try {
|
||||
await page.setViewportSize(viewports[0]);
|
||||
await page.goto(route.url); await ready(page);
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize(viewport); await ready(page);
|
||||
const value = await measure(page);
|
||||
measurements.push({ route: route.label, ...viewport, ...value });
|
||||
verify(value, `${route.label}/${viewport.width}×${viewport.height}`, viewport);
|
||||
const filename = `${route.label}-${viewport.width}x${viewport.height}.png`;
|
||||
await page.screenshot({ path: path.join(output, filename), fullPage: true }); screenshots.push(filename);
|
||||
}
|
||||
if (route.transport && route.label !== 'file-classic') {
|
||||
await page.setViewportSize(viewports[1]); await ready(page);
|
||||
await tooltip(page, route.transport);
|
||||
}
|
||||
} catch (error) { check(false, `${route.label}: browser verification completed`, { message: error.message }); }
|
||||
finally { await page.close(); }
|
||||
}
|
||||
for (const viewport of viewports) {
|
||||
const values = ['html', 'file-classic', 'react', 'storybook'].map(route => measurements.find(item => item.route === route && item.width === viewport.width));
|
||||
check(values.every(Boolean) && values.slice(1).every(value => sameGeometry(values[0], value)), `HTML/React/classic/Story shared Core geometry and asset parity/${viewport.width}`, { routes: values.map(value => value?.route ?? 'missing'), toleranceInBasePixels: 0.4 });
|
||||
}
|
||||
const interactive = await context.newPage(); captureErrors(interactive, 'interactive');
|
||||
await interactive.goto(`${htmlOrigin}/p1.html`); await ready(interactive, 'interactive');
|
||||
const preserved = await interactive.evaluate(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
const node = [...shadow.querySelectorAll('.node')].find(node => Number(node.dataset.organizationDepth ?? node.dataset.depth) >= 2 && node.querySelector('[data-card-kind=leader]'));
|
||||
const card = node?.querySelector('[data-card-kind=leader]');
|
||||
return { organizationId: node?.dataset.organizationId, size: card?.dataset.cardAssetSize, width: card?.offsetWidth, height: card?.offsetHeight, toggleCount: shadow.querySelectorAll('.hm-orgchart-toggle').length, overlayCount: shadow.querySelectorAll('.hm-orgchart-workdesk-overlay').length };
|
||||
});
|
||||
check(preserved.size === 'leader' && preserved.width === 146 && preserved.height === 68 && preserved.toggleCount > 0 && preserved.overlayCount === 0, 'interactive default preserves existing lower-leader large-card policy and controls', preserved);
|
||||
await interactive.close();
|
||||
check(createHash('sha256').update(await readFile(fixturePath)).digest('hex') === fixtureHash, 'P2 browser verifier preserves original employee fixture bytes and IDs', { sha256: fixtureHash });
|
||||
check(errors.length === 0, 'P2 routes have no browser console/runtime errors', errors);
|
||||
} catch (error) { check(false, 'P2 verifier setup/completion', { message: error.message }); }
|
||||
finally {
|
||||
await writeFile(path.join(output, 'browser.json'), JSON.stringify({
|
||||
browser: browser?.version(), scope: 'P2 only; no existing v0/P1 suite expectation changes',
|
||||
transportPolicy: 'HTTP for ESM/React; true file:// classic without file-access browser flag',
|
||||
tooltipMethod: 'Induced 8px name width on one rendered 홍 길 동, restored after real hover/Tab/Escape. Fixture and displayed text are unchanged.',
|
||||
fixtureSha256: fixtureHash,
|
||||
connectorCollisionVerification: 'NOT VERIFIED by automation: path bounding boxes do not represent curved segments. Review saved full-page screenshots for routing and overlap.',
|
||||
textInkCollisionVerification: 'NOT VERIFIED by automation: font Range ascent/descent envelopes may overlap at the confirmed 0px internal row gap. Actual element line boxes, horizontal role/badge boxes, horizontal overflow and card/avatar/logo boundaries are checked; fontRangeOverlaps retain raw envelopes for screenshot pixel review.',
|
||||
results, errors, measurements, screenshots,
|
||||
}, null, 2));
|
||||
await browser?.close();
|
||||
for (const server of servers) await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
const failures = results.filter(result => result.status === 'FAIL');
|
||||
console.log(JSON.stringify({ output, checks: results.length, failureCount: failures.length, failures: failures.slice(0, 20).map(result => result.name), ...(failures.length > 20 ? { additionalFailures: failures.length - 20 } : {}) }, null, 2));
|
||||
if (failures.length) process.exitCode = 1;
|
||||
@@ -0,0 +1,195 @@
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// P3.1 change-specific checks. The prior 732 checks and their evidence are retained.
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const output = path.join(root, 'test-results/org-chart-p3.1');
|
||||
const fixtureBytes = await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'));
|
||||
const fixture = JSON.parse(fixtureBytes);
|
||||
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/index.mjs').href);
|
||||
const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }];
|
||||
const results = [], measurements = [], fontEvidence = [], errors = [], servers = [];
|
||||
const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
|
||||
const near = (a, b, tolerance = .4) => Number.isFinite(a) && Math.abs(a - b) <= tolerance;
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' };
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
async function serve(directory) {
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://127.0.0.1');
|
||||
if (url.pathname === '/__p31_link') return response.end('<!doctype html><link rel="icon" href="data:,"><p id="proof">Group link reached</p>');
|
||||
if (url.pathname === '/__p31_photo.svg') { response.setHeader('Content-Type', 'image/svg+xml'); return response.end('<svg xmlns="http://www.w3.org/2000/svg" width="80" height="100"><rect width="80" height="100" fill="#334455"/></svg>'); }
|
||||
if (url.pathname === '/__p31_bad_photo.png') { response.setHeader('Content-Type', 'image/png'); return response.end('intentional invalid image bytes'); }
|
||||
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
|
||||
const relative = path.relative(directory, file);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
|
||||
response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream');
|
||||
response.end(await fs.readFile(file));
|
||||
} catch { response.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server);
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
async function ready(page, mode = 'readOnly') {
|
||||
await page.waitForFunction(mode => {
|
||||
const s = document.querySelector('.hm-orgchart-host')?.shadowRoot;
|
||||
return s?.querySelector(`.hm-orgchart-composition[data-display-mode="${mode}"] [data-card-asset]`) && [...s.querySelectorAll('link[rel=stylesheet]')].every(l => l.sheet);
|
||||
}, mode, { timeout: 25000 });
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.load('700 20px "Noto Sans KR"', '홍 길 동 협업 소통 허브 경영진 통합시스템');
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(i => i.decode().catch(() => {})));
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
});
|
||||
}
|
||||
async function measure(page) {
|
||||
return page.evaluate(() => {
|
||||
const s = document.querySelector('.hm-orgchart-host').shadowRoot, stage = s.querySelector('.hm-orgchart-stage');
|
||||
const origin = stage.getBoundingClientRect(), scale = origin.width / 1920;
|
||||
const rect = e => { const r = e.getBoundingClientRect(); return { x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }; };
|
||||
const style = (e, props) => Object.fromEntries(props.map(p => [p, getComputedStyle(e)[p]]));
|
||||
const props = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'];
|
||||
const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => ({
|
||||
id: c.dataset.employeeId, size: c.dataset.cardAssetSize, kind: c.dataset.cardKind, box: rect(c),
|
||||
name: c.querySelector('.hm-orgchart-person-name').textContent, role: c.querySelector('.hm-orgchart-person-role').textContent,
|
||||
badge: c.querySelector('.hm-orgchart-job-title')?.textContent ?? '', photo: c.dataset.photoState,
|
||||
image: c.querySelector('.hm-orgchart-avatar img') && { src: c.querySelector('.hm-orgchart-avatar img').src, naturalWidth: c.querySelector('.hm-orgchart-avatar img').naturalWidth, placeholder: c.querySelector('.hm-orgchart-avatar img').dataset.photoPlaceholder },
|
||||
avatar: { box: rect(c.querySelector('.hm-orgchart-avatar')), ...style(c.querySelector('.hm-orgchart-avatar'), ['borderRadius']) },
|
||||
emptyAttributes: !!c.querySelector('.hm-orgchart-person-attributes:empty'),
|
||||
text: [...c.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-job-title')].map(e => ({ text: e.textContent, box: rect(e), clientWidth: e.clientWidth, scrollWidth: e.scrollWidth, ...style(e, props) })),
|
||||
}));
|
||||
const headers = [...s.querySelectorAll('.node[data-organization-id]')].map(n => {
|
||||
const h = n.querySelector('.hm-orgchart-organization-name'), first = n.querySelector('.hm-orgchart-employee');
|
||||
return { id: n.dataset.organizationId, depth: Number(n.dataset.organizationDepth), box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, [...props, 'backgroundImage', 'textAlign', 'borderRadius', 'boxShadow']) };
|
||||
});
|
||||
const hub = s.querySelector('.hm-orgchart-hub'), label = hub.querySelector('.hm-orgchart-hub-label'), icon = hub.querySelector('.hm-orgchart-hub-icon');
|
||||
const cells = [...s.querySelectorAll('.hm-orgchart-workdesk-position-table th,.hm-orgchart-workdesk-position-table td')].map(e => {
|
||||
const r = document.createRange(); r.selectNodeContents(e);
|
||||
const ranges = [...r.getClientRects()].filter(r => r.width > 0).map(r => ({ x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }));
|
||||
return { text: e.textContent, box: rect(e), ranges, ...style(e, ['whiteSpace', 'textOverflow', 'overflowX', 'paddingLeft', 'paddingRight', ...props]) };
|
||||
});
|
||||
const fontViolations = [...s.querySelectorAll('.hm-orgchart-shell *')].filter(e => [...e.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()) && !getComputedStyle(e).fontFamily.includes('Noto Sans KR')).map(e => ({ class: e.className, text: e.textContent, fontFamily: getComputedStyle(e).fontFamily }));
|
||||
const table = s.querySelector('.hm-orgchart-workdesk-position-table');
|
||||
return { scale, cards, headers, hub: { box: rect(hub), icon: rect(icon), label: rect(label), axis: rect(s.querySelector('.hm-orgchart-axis')), ...style(hub, ['paddingLeft', 'paddingRight', 'gap']), font: style(label, props), paths: icon.querySelectorAll('path').length }, cells, fontViolations,
|
||||
table: { box: rect(table), rows: [...table.rows].map(r => [...r.cells].map(c => c.textContent)), total: table.querySelector('[data-total-count]').textContent },
|
||||
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
|
||||
outside: cards.filter(c => c.box.x < -.5 || c.box.y < -.5 || c.box.x + c.box.width > 1920.5 || c.box.y + c.box.height > 915),
|
||||
connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length,
|
||||
footer: s.querySelector('.hm-orgchart-affiliation-bar').textContent,
|
||||
};
|
||||
});
|
||||
}
|
||||
async function glyphFonts(page, route, selectors) {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
await session.send('DOM.enable'); await session.send('CSS.enable');
|
||||
for (const [name, selector] of selectors) {
|
||||
const target = page.locator(selector).first();
|
||||
await target.evaluate(e => e.setAttribute('data-p31-font-proof', 'yes'));
|
||||
const { root: dom } = await session.send('DOM.getDocument', { depth: -1, pierce: true });
|
||||
const find = node => node.attributes?.some((a, i) => i % 2 === 0 && a === 'data-p31-font-proof') ? node : [...(node.children ?? []), ...(node.shadowRoots ?? [])].map(find).find(Boolean);
|
||||
const node = find(dom);
|
||||
const value = await session.send('CSS.getPlatformFontsForNode', { nodeId: node.nodeId });
|
||||
fontEvidence.push({ route, name, ...value });
|
||||
// This variable font's internal family is "Noto Sans KR Thin" even at bold weights.
|
||||
check(value.fonts.length > 0 && value.fonts.every(f => f.familyName.startsWith('Noto Sans KR') && f.postScriptName.startsWith('NotoSansKR') && f.isCustomFont && f.glyphCount > 0), `${route}: actual glyph font ${name}`, value.fonts);
|
||||
await target.evaluate(e => e.removeAttribute('data-p31-font-proof'));
|
||||
}
|
||||
await session.detach();
|
||||
}
|
||||
function verify(m, route, v) {
|
||||
const name = `${route}/${v.width}×${v.height}`;
|
||||
check(!m.fontViolations.length, `${name}: every text family is scoped Noto Sans KR`, m.fontViolations);
|
||||
check(m.cards.length === 43 && m.cards.every(c => c.name === '홍 길 동' && c.role === fixture.employees.find(e => e.id === c.id).position.replace(/\s+/gu, '')), `${name}: display-only name/position; 43 cards`, m.cards.map(c => [c.id,c.name,c.role]));
|
||||
check(m.cards.every(c => c.badge !== '경영진' && c.badge === ((fixture.employees.find(e => e.id === c.id).jobTitle?.trim() === '경영진') ? '' : fixture.employees.find(e => e.id === c.id).jobTitle ?? '')), `${name}: executive badge absent; other raw badges retained`);
|
||||
check(m.cards.every(c => !c.emptyAttributes), `${name}: no empty badge/attribute container`);
|
||||
check(m.cards.every(c => { const [w,h] = c.size === 'representative' ? [146,74] : c.size === 'leader' ? [146,68] : c.size === 'employee' ? [108,52] : [114,54]; return near(c.box.width,w) && near(c.box.height,h); }), `${name}: approved card sizes and existing P3 status sizes preserved`);
|
||||
check(m.cards.every(c => { const [w,h] = c.size === 'representative' ? [36,62] : c.size === 'leader' ? [32,56] : c.size === 'employee' ? [24,44] : [28,28]; return c.photo === 'placeholder' && c.image?.placeholder === 'true' && c.image.naturalWidth > 0 && near(c.avatar.box.width,w) && near(c.avatar.box.height,h) && c.avatar.borderRadius !== '50%'; }) && new Set(m.cards.map(c => c.image?.src)).size === 1, `${name}: common decoded square icon in every noncircular slot`);
|
||||
check(m.headers.filter(h => h.depth === 1).every(h => h.fontSize === '20px' && h.fontWeight === '700' && h.lineHeight === '36px' && near(h.box.height,36)), `${name}: 1Depth 20/700/36 and header36`, m.headers.filter(h => h.depth === 1));
|
||||
check(m.headers.filter(h => h.depth >= 2).every(h => { const small = h.id.startsWith('talent-growth-') || h.id.startsWith('business-planning-'); return h.fontSize === (small ? '18px':'20px') && h.fontWeight === (small?'600':'700') && h.lineHeight === (small?'16.9px':'19.7px') && near(h.box.width,114) && near(h.box.height,30.4); }), `${name}: 2Depth source typography/surface`, m.headers.filter(h => h.depth >= 2));
|
||||
check(m.headers.every(h => h.gap === null || near(h.gap,8)), `${name}: all title-to-first-card gaps 8px`, m.headers.map(h => [h.id,h.gap]));
|
||||
check(m.hub.font.fontSize === '21px' && m.hub.font.fontWeight === '750' && m.hub.font.lineHeight === '24px' && m.hub.paddingLeft === '22px' && m.hub.paddingRight === '24px' && m.hub.gap === '8px' && near(m.hub.box.height,42) && near(m.hub.icon.width,20) && near(m.hub.icon.height,20) && near(m.hub.icon.y+10,m.hub.label.y+m.hub.label.height/2) && near(m.hub.box.x+m.hub.box.width/2,m.hub.axis.x+m.hub.axis.width/2) && m.hub.paths === 3, `${name}: hub font/padding/icon/axis alignment`, m.hub);
|
||||
check(m.cells.every(c => c.whiteSpace === 'nowrap' && c.textOverflow !== 'ellipsis' && c.ranges.length === 1 && c.ranges[0].x >= c.box.x + parseFloat(c.paddingLeft) - .5 && c.ranges[0].x+c.ranges[0].width <= c.box.x+c.box.width-parseFloat(c.paddingRight)+.5), `${name}: every table label fits on one line without clipping`, m.cells.filter(c => c.ranges.length !== 1 || c.ranges[0].width > c.box.width-parseFloat(c.paddingLeft)-parseFloat(c.paddingRight)+.5));
|
||||
check(near(m.table.box.width,330,1) && m.table.total === '41' && m.footer.includes('41'), `${name}: table width/count and footer retained`, { table:m.table.box,total:m.table.total });
|
||||
check(m.document.scrollWidth === m.document.clientWidth && m.document.scrollHeight === m.document.clientHeight && !m.outside.length, `${name}: no page scroll; every card inside stage`, m.document);
|
||||
check(m.cards.every(c => c.text.every(t => t.scrollWidth <= t.clientWidth+1 && t.box.x >= c.box.x-.5 && t.box.x+t.box.width <= c.box.x+c.box.width+.5)), `${name}: card typography has no new horizontal overflow`);
|
||||
}
|
||||
const dialogTargets = [['representative','.hm-orgchart-top-leaders .hm-orgchart-employee'],['depth1','.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'],['depth2','.node[data-organization-depth="2"] .hm-orgchart-employee[data-card-kind="leader"]'],['employee','.hm-orgchart-engine .hm-orgchart-employee[data-card-kind="employee"]'],['dispatched','.hm-orgchart-status-group[data-work-status="dispatched"] .hm-orgchart-employee'],['onLeave','.hm-orgchart-status-group[data-work-status="onLeave"] .hm-orgchart-employee']];
|
||||
async function dialogs(page, route, viewport) {
|
||||
await page.setViewportSize(viewport); await ready(page);
|
||||
for (const [kind, selector] of dialogTargets) {
|
||||
const card = page.locator(selector).first(), dialog = page.locator('dialog[open]');
|
||||
const id = await card.getAttribute('data-employee-id');
|
||||
await card.click(); await dialog.waitFor({ state:'visible' });
|
||||
const value = await dialog.evaluate(d => ({ fields:Object.fromEntries([...d.querySelectorAll('dt')].map(t=>[t.textContent,t.nextElementSibling.textContent])), font:getComputedStyle(d).fontFamily, width:d.getBoundingClientRect().width, scaled:!!d.closest('.hm-orgchart-stage'), transform:getComputedStyle(d).transform }));
|
||||
const employee = fixture.employees.find(e=>e.id===id);
|
||||
check(value.fields['이름']==='홍 길 동' && value.fields['직위']===employee.position && (!employee.jobTitle || value.fields['직책']===employee.jobTitle) && value.font.includes('Noto Sans KR') && !value.scaled && value.transform==='none' && near(value.width,Math.min(420,viewport.width-32)), `${route}/${viewport.width}: ${kind} popup retains data/font/unscaled size`, value);
|
||||
if (kind==='representative' && viewport.width===1920) await glyphFonts(page,route,[['popup name','dialog[open] dd']]);
|
||||
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
|
||||
check(await dialog.evaluate(d=>d.contains(d.getRootNode().activeElement)),`${route}/${viewport.width}: ${kind} modal focus trap`);
|
||||
await page.keyboard.press('Escape'); await dialog.waitFor({state:'hidden'});
|
||||
check(await card.evaluate(e=>e.getRootNode().activeElement===e),`${route}/${viewport.width}: ${kind} ESC focus return`);
|
||||
if(kind==='representative') { await card.press('Enter'); await dialog.waitFor({state:'visible'}); await dialog.locator('button').click(); await dialog.waitFor({state:'hidden'}); await card.press('Space'); await dialog.waitFor({state:'visible'}); await page.keyboard.press('Escape'); check(await card.evaluate(e=>e.getRootNode().activeElement===e),`${route}/${viewport.width}: Enter/Space/close preserve activation`); }
|
||||
}
|
||||
}
|
||||
async function setData(page,data,transport) { await page.evaluate(async({data,transport})=>{ if(transport==='react')window.OrgChartP1React.setData(data);else await window.OrgChartP1.setData(data); },{data,transport}); await ready(page); }
|
||||
let browser;
|
||||
try {
|
||||
const htmlOrigin = await serve(path.join(root,'examples/org-chart/dist'));
|
||||
const reactOrigin = await serve(path.join(root,'examples/org-chart-react/dist'));
|
||||
browser = await chromium.launch({executablePath:process.env.ORGCHART_CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe',headless:true});
|
||||
const context = await browser.newContext();
|
||||
const routes = [{name:'html',url:`${htmlOrigin}/p1.html?mode=readOnly`},{name:'file-classic',url:pathToFileURL(path.join(root,'examples/org-chart/dist/p1-classic.html')).href+'?mode=readOnly'},{name:'react',url:`${reactOrigin}/p1.html?mode=readOnly`},{name:'storybook',url:'http://127.0.0.1:6041/iframe.html?id=orgchart--full-composition&viewMode=story'}];
|
||||
for(const route of routes) {
|
||||
const page = await context.newPage(); page.on('pageerror',e=>errors.push({route:route.name,type:'runtime',message:e.message})); page.on('console',e=>{if(e.type()==='error')errors.push({route:route.name,type:'console',message:e.text()});});
|
||||
try {
|
||||
await page.goto(route.url); await ready(page);
|
||||
for(const viewport of viewports) { await page.setViewportSize(viewport); await ready(page); const m = await measure(page); measurements.push({route:route.name,...viewport,...m}); verify(m,route.name,viewport); if([1920,390].includes(viewport.width))await page.screenshot({path:path.join(output,`${route.name}-${viewport.width}x${viewport.height}.png`)}); }
|
||||
await page.setViewportSize(viewports[0]); await ready(page);
|
||||
await glyphFonts(page,route.name,[['card name','.hm-orgchart-person-name'],['card position','.hm-orgchart-person-role'],['1Depth','.node[data-organization-depth="1"] .hm-orgchart-organization-name'],['2Depth','.node[data-organization-depth="2"] .hm-orgchart-organization-name'],['hub','.hm-orgchart-hub-label'],['table','.hm-orgchart-workdesk-position-table th'],['footer','.hm-orgchart-affiliation-bar-item dt'],['status','.hm-orgchart-status-group .hm-orgchart-summary-title']]);
|
||||
await dialogs(page,route.name,viewports[0]); await dialogs(page,route.name,viewports.at(-1));
|
||||
if(['html','react'].includes(route.name)) {
|
||||
const data = structuredClone(fixture); data.employees[0].profileImage=`${htmlOrigin}/__p31_photo.svg`; data.employees[1].profileImage=`${htmlOrigin}/__p31_bad_photo.png`; data.employees[2].position='책 임';
|
||||
await setData(page,data,route.name);
|
||||
const imageState = await page.evaluate(ids=>ids.map(id=>{const c=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`[data-employee-id="${id}"]`),i=c.querySelector('.hm-orgchart-avatar img');return {id,photo:c.dataset.photoState,src:i.src,width:i.naturalWidth,role:c.querySelector('.hm-orgchart-person-role').textContent};}),data.employees.slice(0,3).map(e=>e.id));
|
||||
check(imageState[0].photo==='image' && imageState[0].width===80 && imageState[1].photo==='placeholder' && imageState[1].width===64 && imageState[2].role==='책임' && data.employees[2].position==='책 임',`${route.name}: real image precedence/load-failure fallback/display whitespace`,imageState);
|
||||
await setData(page,fixture,route.name);
|
||||
// The preserved fixture has position=경영진 but no jobTitle=경영진.
|
||||
// Exercise the requested badge policy with in-memory variants, never edit JSON.
|
||||
const executiveIds = await page.evaluate(selectors=>selectors.map(selector=>document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(selector).dataset.employeeId),dialogTargets.map(t=>t[1]));
|
||||
const executiveData = structuredClone(fixture);
|
||||
for(const employee of executiveData.employees)if(executiveIds.includes(employee.id))employee.jobTitle='경영진';
|
||||
await setData(page,executiveData,route.name);
|
||||
const executiveState = await page.evaluate(ids=>[...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(c=>ids.includes(c.dataset.employeeId)).map(c=>({id:c.dataset.employeeId,badge:c.querySelector('.hm-orgchart-job-title')?.textContent,role:c.querySelector('.hm-orgchart-person-role').textContent,empty:!!c.querySelector('.hm-orgchart-person-attributes:empty')})),executiveIds);
|
||||
check(executiveState.length>=6 && executiveState.every(c=>!c.badge&&!c.empty&&c.role===fixture.employees.find(e=>e.id===c.id).position),`${route.name}: executive badge suppressed in all six card roles; positions preserved`,executiveState);
|
||||
await page.locator(dialogTargets[0][1]).click();await page.locator('dialog[open]').waitFor({state:'visible'});
|
||||
const rawRole=await page.locator('dialog[open]').evaluate(d=>[...d.querySelectorAll('dt')].find(t=>t.textContent==='직책')?.nextElementSibling.textContent);
|
||||
check(rawRole==='경영진' && executiveData.employees.find(e=>e.id===executiveIds[0]).jobTitle==='경영진',`${route.name}: hidden badge remains in data and popup contract`);await page.keyboard.press('Escape');
|
||||
await setData(page,fixture,route.name);
|
||||
const id=fixture.organizations.find(o=>o.parentId===fixture.organizations.find(o=>o.parentId===null).id).id;
|
||||
const links={[id]:`${route.name==='html'?htmlOrigin:reactOrigin}/__p31_link`};
|
||||
await page.evaluate(async({links,transport})=>{if(transport==='react')window.OrgChartP1React.setOptions({organizationLinks:links});else {const s=window.OrgChartP1;s.chart.destroy();s.chart=s.api.createOrgChart({container:document.getElementById('chart-host'),dataSource:new s.api.JsonOrgChartDataSource(s.data),stylesheetUrls:s.stylesheetUrls,displayMode:'readOnly',organizationLinks:links});await s.chart.mount();}},{links,transport:route.name}); await ready(page);
|
||||
const link=page.locator('.hm-orgchart-organization-link');check(await link.count()===1,`${route.name}: only configured 1Depth title links`);await link.focus();await Promise.all([page.waitForURL(links[id]),page.keyboard.press('Enter')]);check(await page.locator('#proof').textContent()==='Group link reached',`${route.name}: keyboard group link navigation`);
|
||||
}
|
||||
} catch(error) { check(false,`${route.name}: focused verification completed`,{message:error.message,stack:error.stack}); }
|
||||
await page.close();
|
||||
}
|
||||
for(const v of viewports) {
|
||||
const variants=measurements.filter(m=>m.width===v.width);
|
||||
const base=variants.find(m=>m.route==='html');
|
||||
const equal=(a,b)=>['x','y','width','height'].every(k=>near(a[k],b[k]));
|
||||
check(variants.length===4 && variants.every(m=>m.cards.length===base.cards.length && m.cards.every((c,i)=>equal(c.box,base.cards[i].box)&&c.name===base.cards[i].name&&c.role===base.cards[i].role&&c.badge===base.cards[i].badge&&c.image.src===base.cards[i].image.src)&&equal(m.hub.box,base.hub.box)&&JSON.stringify(m.table.rows)===JSON.stringify(base.table.rows)&&equal(m.table.box,base.table.box)),`HTML/classic/React/Storybook identical content/geometry/${v.width}`);
|
||||
}
|
||||
const standalone=await context.newPage();await standalone.goto(`${htmlOrigin}/p1.html`);await ready(standalone,'interactive');
|
||||
const standaloneData=structuredClone(fixture);standaloneData.employees[0].jobTitle='경영진';await standalone.evaluate(async data=>window.OrgChartP1.setData(data),standaloneData);await ready(standalone,'interactive');
|
||||
check((await standalone.locator('.hm-orgchart-job-title').allTextContents()).includes('경영진'),'shared renderer preserves executive badge outside readOnly Full Composition');await standalone.close();
|
||||
const index=await fetch('http://127.0.0.1:6041/index.json').then(r=>r.json());const userStories=Object.values(index.entries).filter(e=>e.title==='OrgChart'&&e.type==='story'&&!e.tags?.includes('internal'));check(userStories.length===3,'existing Storybook three user menus retained',userStories.map(e=>e.name));
|
||||
check(!errors.length,'no new browser console/runtime errors',errors);
|
||||
} finally {
|
||||
await browser?.close(); for(const server of servers)await new Promise(r=>server.close(r));
|
||||
const summary={total:results.length,pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length};
|
||||
await fs.writeFile(path.join(output,'browser.json'),JSON.stringify({scope:'P3.1 only; P3 732 checks retained without rerun',fixtureSha256:createHash('sha256').update(fixtureBytes).digest('hex'),summary,results,measurements,fontEvidence,errors},null,2));
|
||||
console.log(JSON.stringify({summary,errors,failures:results.filter(r=>r.status==='FAIL').map(r=>r.name)},null,2));if(summary.fail)process.exitCode=1;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// P3.2 change-specific evidence. Existing P3.1 486-check evidence is preserved.
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const output = path.join(root, 'test-results/org-chart-p3.2');
|
||||
const baselineMode = process.argv.includes('--baseline');
|
||||
const policiesOnly = process.argv.includes('--policies-only');
|
||||
const fixtureBytes = await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'));
|
||||
const fixture = JSON.parse(fixtureBytes);
|
||||
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/index.mjs').href);
|
||||
const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }];
|
||||
const storyOrigin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6041';
|
||||
const results = [], measurements = [], errors = [], servers = [];
|
||||
const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
|
||||
const near = (a, b, tolerance = .4) => Number.isFinite(a) && Math.abs(a - b) <= tolerance;
|
||||
const sameBox = (a, b) => a && b && ['x', 'y', 'width', 'height'].every(k => near(a[k], b[k]));
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' };
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
const previous = baselineMode || policiesOnly ? null : JSON.parse(await fs.readFile(path.join(output, 'baseline-browser.json'), 'utf8'));
|
||||
async function serve(directory) {
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://127.0.0.1');
|
||||
if (url.pathname === '/__p32_link') return response.end('<!doctype html><link rel="icon" href="data:,"><p id="proof">Group link reached</p>');
|
||||
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
|
||||
const relative = path.relative(directory, file);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
|
||||
response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream');
|
||||
response.end(await fs.readFile(file));
|
||||
} catch { response.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server);
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
async function ready(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const s = document.querySelector('.hm-orgchart-host')?.shadowRoot;
|
||||
return s?.querySelector('.hm-orgchart-composition[data-display-mode="readOnly"] [data-card-asset]') && [...s.querySelectorAll('link[rel=stylesheet]')].every(l => l.sheet);
|
||||
}, null, { timeout: 25000 });
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.load('600 18px "Noto Sans KR"', '홍 길 동');
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(i => i.decode().catch(() => {})));
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
});
|
||||
}
|
||||
async function measure(page) {
|
||||
return page.evaluate(() => {
|
||||
const s = document.querySelector('.hm-orgchart-host').shadowRoot, stage = s.querySelector('.hm-orgchart-stage');
|
||||
const origin = stage.getBoundingClientRect(), scale = origin.width / 1920;
|
||||
const rect = e => { const r = e.getBoundingClientRect(); return { x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }; };
|
||||
const relative = (r, c) => ({ x: r.x - c.x, y: r.y - c.y, width: r.width, height: r.height });
|
||||
const style = (e, props) => Object.fromEntries(props.map(p => [p, getComputedStyle(e)[p]]));
|
||||
const typography = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'letterSpacing'];
|
||||
const contained = (a, b, tolerance = .5) => a.x >= b.x - tolerance && a.y >= b.y - tolerance && a.x + a.width <= b.x + b.width + tolerance && a.y + a.height <= b.y + b.height + tolerance;
|
||||
const overlap = (a, b) => a && b && Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > .5 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > .5;
|
||||
const inks = new Map();
|
||||
// Reuse the approved P3 logo test method: natural PNG alpha bounds (>16),
|
||||
// transformed through object-fit:contain. Transparent image padding is excluded.
|
||||
const logoPixels = img => {
|
||||
if (!img?.naturalWidth) return null;
|
||||
if (!inks.has(img.src)) {
|
||||
const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
|
||||
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let left = canvas.width, right = -1, top = canvas.height, bottom = -1;
|
||||
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) if (pixels[(y * canvas.width + x) * 4 + 3] > 16) {
|
||||
left = Math.min(left, x); right = Math.max(right, x + 1); top = Math.min(top, y); bottom = Math.max(bottom, y + 1);
|
||||
}
|
||||
inks.set(img.src, { left, right, top, bottom });
|
||||
}
|
||||
const a = inks.get(img.src), b = rect(img), fit = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight);
|
||||
const x = b.x + (b.width - img.naturalWidth * fit) / 2, y = b.y + (b.height - img.naturalHeight * fit) / 2;
|
||||
return { x: x + a.left * fit, y: y + a.top * fit, width: (a.right - a.left) * fit, height: (a.bottom - a.top) * fit };
|
||||
};
|
||||
const textMeasure = e => {
|
||||
const box = rect(e), r = document.createRange(); r.selectNodeContents(e);
|
||||
const ranges = [...r.getClientRects()].filter(r => r.width > 0).map(r => ({ x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }));
|
||||
return { text: e.textContent, box, ranges, clientWidth: e.clientWidth, scrollWidth: e.scrollWidth, ...style(e, typography) };
|
||||
};
|
||||
const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => {
|
||||
const box = rect(c), status = c.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? null;
|
||||
const avatar = rect(c.querySelector('.hm-orgchart-avatar')), mark = c.querySelector('.hm-orgchart-affiliation-mark'), img = mark?.querySelector('img');
|
||||
const imageBox = img && rect(img), fit = img?.naturalWidth ? Math.min(imageBox.width / img.naturalWidth, imageBox.height / img.naturalHeight) : 0;
|
||||
const contentBox = img ? { x: imageBox.x + (imageBox.width - img.naturalWidth * fit) / 2, y: imageBox.y + (imageBox.height - img.naturalHeight * fit) / 2, width: img.naturalWidth * fit, height: img.naturalHeight * fit } : null;
|
||||
const logo = img ? { box: imageBox, contentBox, local: relative(imageBox, box), ink: logoPixels(img), naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, src: img.src, ...style(img, ['objectFit', 'filter', 'opacity', 'backgroundColor']) } : null;
|
||||
const text = [...c.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-job-title')].map(textMeasure);
|
||||
const collisions = [], outside = [];
|
||||
if (!contained(avatar, box)) outside.push('avatar');
|
||||
if (logo && !contained(logo.box, box)) outside.push('logo image box');
|
||||
if (logo?.ink && overlap(logo.ink, avatar)) collisions.push('logo/avatar');
|
||||
for (const t of text) {
|
||||
if (t.scrollWidth > t.clientWidth + 1) outside.push(`${t.text}: horizontal overflow`);
|
||||
for (const r of t.ranges) {
|
||||
// Text Range includes font ascent/descent, and is conservative for logo
|
||||
// and avatar tests. Adjacent Range-to-Range intersections are recorded
|
||||
// separately; they are not treated as painted glyph collisions.
|
||||
const visible = { x: Math.max(r.x, t.box.x), y: r.y, width: Math.max(0, Math.min(r.x + r.width, t.box.x + t.box.width) - Math.max(r.x, t.box.x)), height: r.height };
|
||||
if (!contained(r, box)) outside.push(`${t.text}: text Range outside card`);
|
||||
if (overlap(visible, avatar)) collisions.push(`${t.text}/avatar`);
|
||||
if (logo?.ink && overlap(visible, logo.ink)) collisions.push(`${t.text}/logo ink`);
|
||||
}
|
||||
}
|
||||
const fontRangeOverlaps = [];
|
||||
for (const [i, a] of text.entries()) for (const b of text.slice(i + 1)) for (const ar of a.ranges) for (const br of b.ranges) if (overlap(ar, br)) fontRangeOverlaps.push([a.text, b.text]);
|
||||
const name = textMeasure(c.querySelector('.hm-orgchart-person-name')), role = textMeasure(c.querySelector('.hm-orgchart-person-role'));
|
||||
const photo = c.querySelector('.hm-orgchart-avatar img');
|
||||
return { key: `${status ?? 'tree'}:${c.dataset.employeeId}`, id: c.dataset.employeeId, status, size: c.dataset.cardAssetSize ?? null, kind: c.dataset.cardKind, box,
|
||||
name, role, badge: c.querySelector('.hm-orgchart-job-title')?.textContent ?? '', statusLabel: c.querySelector('.hm-orgchart-status-label')?.textContent ?? '',
|
||||
avatar: { box: avatar, local: relative(avatar, box), photoState: c.dataset.photoState, placeholder: photo.dataset.photoPlaceholder, naturalWidth: photo.naturalWidth },
|
||||
nameLocal: relative(name.box, box), roleLocal: relative(role.box, box), text, logo, mark: mark ? { box: rect(mark), ...style(mark, ['backgroundColor', 'border', 'boxShadow']) } : null,
|
||||
surface: style(c, ['backgroundImage', 'border', 'borderRadius', 'padding', 'columnGap']), collisions, outside, fontRangeOverlaps };
|
||||
});
|
||||
const headers = [...s.querySelectorAll('.node[data-organization-id]')].map(n => {
|
||||
const h = n.querySelector('.hm-orgchart-organization-name'), first = n.querySelector('.hm-orgchart-employee');
|
||||
return { id: n.dataset.organizationId, depth: Number(n.dataset.organizationDepth), box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, typography) };
|
||||
});
|
||||
const statusHeaders = [...s.querySelectorAll('.hm-orgchart-status-group')].map(g => {
|
||||
const h = g.querySelector('.hm-orgchart-summary-title'), first = g.querySelector('.hm-orgchart-employee');
|
||||
return { status: g.dataset.workStatus, text: h.textContent, box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, [...typography, 'backgroundImage']) };
|
||||
});
|
||||
const footer = s.querySelector('.hm-orgchart-affiliation-bar'), table = s.querySelector('.hm-orgchart-workdesk-position-table');
|
||||
return { scale, cards, headers, statusHeaders,
|
||||
footer: { box: rect(footer), text: footer.textContent, logos: [...footer.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(img => ({ id: img.parentElement.dataset.affiliationId, box: rect(img), ...style(img, ['width', 'height']) })) },
|
||||
table: { box: rect(table), text: table.textContent, total: table.querySelector('[data-total-count]').textContent },
|
||||
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
|
||||
policies: { displayMode: s.querySelector('.hm-orgchart-composition').dataset.displayMode, collapseButtons: s.querySelectorAll('.hm-orgchart-toggle').length, connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length, arrows: s.querySelectorAll('.hm-orgchart-workdesk-overlay [data-arrow]').length, background: getComputedStyle(s.querySelector('.hm-orgchart-composition')).backgroundColor },
|
||||
stage: rect(stage) };
|
||||
});
|
||||
}
|
||||
async function measurePolicies(page) {
|
||||
return page.evaluate(() => {
|
||||
const s = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
const composition = s.querySelector('.hm-orgchart-composition');
|
||||
return { displayMode: composition.dataset.displayMode, collapseButtons: s.querySelectorAll('.hm-orgchart-toggle').length,
|
||||
connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length,
|
||||
arrows: s.querySelectorAll('.hm-orgchart-workdesk-overlay [data-arrow]').length,
|
||||
background: getComputedStyle(composition).backgroundColor };
|
||||
});
|
||||
}
|
||||
function verify(m, before, route, viewport) {
|
||||
const label = `${route}/${viewport.width}×${viewport.height}`;
|
||||
check(m.cards.length === before.cards.length && m.cards.every(c => before.cards.some(b => b.key === c.key)), `${label}: same employee IDs and rendered count`);
|
||||
check(m.cards.every(c => c.name.text === '홍 길 동' && !/\s/u.test(c.role.text) && !/\s/u.test(c.badge) && c.name.fontFamily.includes('Noto Sans KR') && c.role.fontFamily.includes('Noto Sans KR')), `${label}: Noto Sans KR, spaced names and compact position/badge text`);
|
||||
check(m.cards.every(c => {
|
||||
const b = before.cards.find(b => b.key === c.key);
|
||||
const preserved = ['fontWeight', 'lineHeight', 'color', 'letterSpacing'];
|
||||
if (c.status) return c.name.fontSize === '18px' && c.role.fontSize === '15px';
|
||||
if (c.size === 'representative') return [...preserved, 'fontSize'].every(p => c.name[p] === b.name[p] && c.role[p] === b.role[p]);
|
||||
return c.name.fontSize === (c.size === 'leader' ? '24px' : '18px') && c.role.fontSize === (c.size === 'leader' ? '18px' : '15px') && preserved.every(p => c.name[p] === b.name[p] && c.role[p] === b.role[p]);
|
||||
}), `${label}: requested card typography; representative and other approved properties preserved`);
|
||||
const general = m.headers.find(h => h.id === 'integrated-system-1');
|
||||
check(general && m.statusHeaders.every(h => ['fontSize', 'fontWeight', 'lineHeight'].every(p => h[p] === general[p]) && h.backgroundImage === before.statusHeaders.find(b => b.status === h.status).backgroundImage), `${label}: status title equals standard 2Depth typography; gray gradient retained`, m.statusHeaders);
|
||||
const normal = m.cards.find(c => !c.status && c.size === 'employee' && c.kind === 'employee' && !c.badge);
|
||||
const common = ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'];
|
||||
check(normal && m.cards.filter(c => c.status).every(c => sameBox(c.avatar.local, normal.avatar.local) && sameBox(c.nameLocal, normal.nameLocal) && sameBox(c.roleLocal, normal.roleLocal) && common.every(p => c.name[p] === normal.name[p] && c.role[p] === normal.role[p]) && c.statusLabel === '' && c.name.color === before.cards.find(b => b.key === c.key).name.color && c.role.color === before.cards.find(b => b.key === c.key).role.color), `${label}: status photo/text geometry and typography equals compact cards; own colors retained; no status line`, m.cards.filter(c => c.status).map(c => ({ key: c.key, avatar: c.avatar.local, name: c.nameLocal, role: c.roleLocal })));
|
||||
check(m.cards.filter(c => c.status).every(c => { const b = before.cards.find(b => b.key === c.key); return ['backgroundImage', 'border', 'borderRadius'].every(p => c.surface[p] === b.surface[p]) && c.logo.filter === b.logo.filter && c.logo.opacity === b.logo.opacity; }), `${label}: status card surface and image-only grayscale/opacity retained`);
|
||||
check(m.cards.every(c => { const [w, h] = c.size === 'representative' ? [146, 74] : c.size === 'leader' ? [146, 68] : [108, 52]; return near(c.box.width, w) && near(c.box.height, h); }), `${label}: card sizes remain 146×74/146×68/108×52; status corrected to requested108×52`);
|
||||
check([...m.headers, ...m.statusHeaders].every(h => h.gap === null || near(h.gap, 8)), `${label}: every title↔first card gap8px`);
|
||||
check(m.cards.every(c => c.logo && c.logo.naturalWidth && c.logo.objectFit === 'contain' && c.logo.src === before.cards.find(b => b.key === c.key).logo?.src && ['width', 'height'].every(p => { const old = before.cards.find(b => b.key === c.key).logo; const fitted = old.contentBox ?? { width: old.naturalWidth * Math.min(old.box.width / old.naturalWidth, old.box.height / old.naturalHeight), height: old.naturalHeight * Math.min(old.box.width / old.naturalWidth, old.box.height / old.naturalHeight) }; return near(c.logo.contentBox[p] / fitted[p], 1.5, .005); }) && ['width', 'height'].every(p => near(c.logo.ink[p] / before.cards.find(b => b.key === c.key).logo.ink[p], 1.5, .005))), `${label}: all actual card logo contained images and painted ink scale1.5×; assets/ratio retained`, m.cards.map(c => ({ key: c.key, image: c.logo?.box, fitted: c.logo?.contentBox, ink: c.logo?.ink })));
|
||||
check(m.cards.every(c => c.mark.backgroundColor === 'rgba(0, 0, 0, 0)' && c.mark.border.startsWith('0px none') && c.mark.boxShadow === 'none'), `${label}: card logos have no added frame/background/shadow`);
|
||||
check(m.cards.every(c => !c.collisions.length && !c.outside.length), `${label}: no logo/avatar/visible text overlap or clipping`, m.cards.filter(c => c.collisions.length || c.outside.length).map(c => ({ key: c.key, collisions: c.collisions, outside: c.outside })));
|
||||
check(m.cards.every(c => c.avatar.photoState === 'placeholder' && c.avatar.placeholder === 'true' && c.avatar.naturalWidth > 0 && c.badge !== '경영진'), `${label}: common photo placeholder and no executive badge`);
|
||||
check(m.footer.text === before.footer.text && sameBox(m.footer.box, before.footer.box) && m.footer.logos.length === before.footer.logos.length && m.footer.logos.every((l, i) => sameBox(l.box, before.footer.logos[i].box)), `${label}: footer counts/geometry/logos unchanged`);
|
||||
check(m.table.text === before.table.text && m.table.total === '41' && sameBox(m.table.box, before.table.box) && m.policies.displayMode === 'readOnly' && m.policies.collapseButtons === 0 && m.policies.connectorPaths === before.policies.connectorPaths && m.policies.arrows === 5 && m.policies.background === 'rgb(245, 248, 251)', `${label}: readOnly/background/connectors/statistics policies retained`, m.policies);
|
||||
check(m.document.scrollWidth === m.document.clientWidth && m.document.scrollHeight === m.document.clientHeight && m.cards.every(c => c.box.x >= -.5 && c.box.y >= -.5 && c.box.x + c.box.width <= 1920.5 && c.box.y + c.box.height <= 915), `${label}: no page scroll; cards contained in scaled stage`, m.document);
|
||||
}
|
||||
const dialogTargets = [['representative', '.hm-orgchart-top-leaders .hm-orgchart-employee'], ['depth1', '.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'], ['small', '.hm-orgchart-engine .hm-orgchart-employee[data-card-asset-size="employee"]'], ['dispatched', '.hm-orgchart-status-group[data-work-status="dispatched"] .hm-orgchart-employee'], ['onLeave', '.hm-orgchart-status-group[data-work-status="onLeave"] .hm-orgchart-employee']];
|
||||
async function dialogs(page, route, viewport) {
|
||||
await page.setViewportSize(viewport); await ready(page);
|
||||
for (const [kind, selector] of dialogTargets) {
|
||||
const card = page.locator(selector).first(), dialog = page.locator('dialog[open]'), id = await card.getAttribute('data-employee-id');
|
||||
await card.click(); await dialog.waitFor({ state: 'visible' });
|
||||
const value = await dialog.evaluate(d => ({ fields: Object.fromEntries([...d.querySelectorAll('dt')].map(t => [t.textContent, t.nextElementSibling.textContent])), scaled: !!d.closest('.hm-orgchart-stage'), transform: getComputedStyle(d).transform, width: d.getBoundingClientRect().width }));
|
||||
const employee = fixture.employees.find(e => e.id === id);
|
||||
check(value.fields['이름'] === '홍 길 동' && value.fields['직위'] === employee.position && (!employee.jobTitle || value.fields['직책'] === employee.jobTitle) && !value.scaled && value.transform === 'none' && near(value.width, Math.min(420, viewport.width - 32)), `${route}/${viewport.width}: ${kind} popup retains data/unscaled size`, value);
|
||||
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
|
||||
check(await dialog.evaluate(d => d.contains(d.getRootNode().activeElement)), `${route}/${viewport.width}: ${kind} popup focus trap`);
|
||||
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' });
|
||||
check(await card.evaluate(e => e.getRootNode().activeElement === e), `${route}/${viewport.width}: ${kind} ESC focus return`);
|
||||
}
|
||||
}
|
||||
async function links(page, route, origin) {
|
||||
const id = fixture.organizations.find(o => o.parentId === fixture.organizations.find(o => o.parentId === null).id).id;
|
||||
const configured = { [id]: `${origin}/__p32_link` };
|
||||
await page.evaluate(async ({ configured, transport }) => {
|
||||
if (transport === 'react') window.OrgChartP1React.setOptions({ organizationLinks: configured });
|
||||
else { const s = window.OrgChartP1; s.chart.destroy(); s.chart = s.api.createOrgChart({ container: document.getElementById('chart-host'), dataSource: new s.api.JsonOrgChartDataSource(s.data), stylesheetUrls: s.stylesheetUrls, displayMode: 'readOnly', organizationLinks: configured }); await s.chart.mount(); }
|
||||
}, { configured, transport: route });
|
||||
await ready(page);
|
||||
const link = page.locator('.hm-orgchart-organization-link'); check(await link.count() === 1, `${route}: only configured 1Depth title becomes link`);
|
||||
await link.focus(); await Promise.all([page.waitForURL(configured[id]), page.keyboard.press('Enter')]);
|
||||
check(await page.locator('#proof').textContent() === 'Group link reached', `${route}: real1Depth keyboard link navigation`);
|
||||
}
|
||||
let browser;
|
||||
try {
|
||||
const htmlOrigin = await serve(path.join(root, 'examples/org-chart/dist'));
|
||||
const reactOrigin = await serve(path.join(root, 'examples/org-chart-react/dist'));
|
||||
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
|
||||
const context = await browser.newContext();
|
||||
const routes = [{ name: 'html', url: `${htmlOrigin}/p1.html?mode=readOnly`, origin: htmlOrigin }, { name: 'file-classic', url: pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href + '?mode=readOnly' }, { name: 'react', url: `${reactOrigin}/p1.html?mode=readOnly`, origin: reactOrigin }, { name: 'storybook', url: `${storyOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story` }];
|
||||
for (const route of routes) {
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', e => errors.push({ route: route.name, type: 'runtime', message: e.message }));
|
||||
page.on('console', e => { if (e.type() === 'error') errors.push({ route: route.name, type: 'console', message: e.text() }); });
|
||||
try {
|
||||
await page.goto(route.url); await ready(page);
|
||||
if (policiesOnly) {
|
||||
await page.setViewportSize(viewports[0]); await ready(page);
|
||||
const policies = await measurePolicies(page);
|
||||
measurements.push({ route: route.name, ...viewports[0], policies });
|
||||
check(policies.displayMode === 'readOnly' && policies.collapseButtons === 0, `${route.name}: readOnly has no actual hm-orgchart-toggle controls`, policies);
|
||||
check(policies.background === 'rgb(245, 248, 251)', `${route.name}: actual composition background is #F5F8FB`, policies.background);
|
||||
check(policies.connectorPaths === 5 && policies.arrows === 5, `${route.name}: actual overlay retains five connector paths and five collaboration arrows`, policies);
|
||||
} else {
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize(viewport); await ready(page);
|
||||
const m = await measure(page); measurements.push({ route: route.name, ...viewport, ...m });
|
||||
if (!baselineMode) verify(m, previous.measurements.find(b => b.route === route.name && b.width === viewport.width), route.name, viewport);
|
||||
if ([1920, 390].includes(viewport.width)) await page.screenshot({ path: path.join(output, `${baselineMode ? 'baseline-' : ''}${route.name}-${viewport.width}x${viewport.height}.png`) });
|
||||
}
|
||||
if (!baselineMode) {
|
||||
await dialogs(page, route.name, viewports[0]); await dialogs(page, route.name, viewports.at(-1));
|
||||
if (['html', 'react'].includes(route.name)) await links(page, route.name, route.origin);
|
||||
}
|
||||
}
|
||||
} catch (error) { check(false, `${route.name}: focused browser capture completed`, { message: error.message, stack: error.stack }); }
|
||||
await page.close();
|
||||
}
|
||||
if (policiesOnly) check(!errors.length, 'no new console/runtime errors during policy-only capture', errors);
|
||||
else if (!baselineMode) {
|
||||
for (const viewport of viewports) {
|
||||
const variants = measurements.filter(m => m.width === viewport.width), base = variants.find(m => m.route === 'html');
|
||||
check(variants.length === 4 && variants.every(m => m.cards.length === base.cards.length && m.cards.every((c, i) => sameBox(c.box, base.cards[i].box) && sameBox(c.nameLocal, base.cards[i].nameLocal) && sameBox(c.roleLocal, base.cards[i].roleLocal) && c.name.fontSize === base.cards[i].name.fontSize && c.role.fontSize === base.cards[i].role.fontSize && sameBox(c.logo.box, base.cards[i].logo.box))), `HTML/classic/React/Storybook same card typography/geometry/${viewport.width}`);
|
||||
}
|
||||
const index = await fetch(`${storyOrigin}/index.json`).then(r => r.json());
|
||||
const stories = Object.values(index.entries).filter(e => e.title === 'OrgChart' && e.type === 'story' && !e.tags?.includes('internal'));
|
||||
check(stories.length === 3, 'existing Storybook three user menus retained', stories.map(e => e.name));
|
||||
check(!errors.length, 'no new console/runtime errors', errors);
|
||||
}
|
||||
} finally {
|
||||
await browser?.close(); for (const server of servers) await new Promise(resolve => server.close(resolve));
|
||||
const summary = { total: results.length, pass: results.filter(r => r.status === 'PASS').length, fail: results.filter(r => r.status === 'FAIL').length };
|
||||
const report = { scope: policiesOnly ? 'P3.2 measurement-selector correction only; retain browser.json and do not repeat passed typography/logo/layout/dialog/link checks' : 'P3.2 changes only; prior P3.1 486 checks retained without rerun', baselineMode, policiesOnly, fixtureSha256: createHash('sha256').update(fixtureBytes).digest('hex'), summary, results, measurements, errors,
|
||||
collisionMethod: 'Natural PNG alpha bounds >16 transformed through object-fit contain, versus visible text Range and avatar rectangles. Adjacent font Range envelopes are recorded only; painted font glyph interpenetration is not certified by this test.' };
|
||||
await fs.writeFile(path.join(output, policiesOnly ? 'policy-browser.json' : baselineMode ? 'baseline-browser.json' : 'browser.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify({ baselineMode, policiesOnly, captures: measurements.length, summary, errors, failures: results.filter(r => r.status === 'FAIL').map(r => r.name) }, null, 2));
|
||||
if (summary.fail || (baselineMode && measurements.length !== routesLength())) process.exitCode = 1;
|
||||
}
|
||||
function routesLength() { return 4 * viewports.length; }
|
||||
@@ -0,0 +1,436 @@
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// P3 impact checks only. P2's 484 checks and every earlier report are retained.
|
||||
// Runtime layout, accessibility interaction, and data aggregation are measured;
|
||||
// font ink and curved connector routing still require saved-image review.
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const output = path.join(root, 'test-results/org-chart-p3');
|
||||
const fixturePath = path.join(root, 'examples/org-chart/data-p1.json');
|
||||
const fixtureBytes = await readFile(fixturePath);
|
||||
const fixtureHash = createHash('sha256').update(fixtureBytes).digest('hex');
|
||||
const fixture = JSON.parse(fixtureBytes.toString('utf8'));
|
||||
const playwrightPath = process.env.ORGCHART_PLAYWRIGHT_MODULE ?? 'C:\\Users\\user\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\node\\node_modules\\playwright\\index.mjs';
|
||||
const { chromium } = await import(pathToFileURL(playwrightPath).href);
|
||||
const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }];
|
||||
const results = [], measurements = [], interactions = [], errors = [], screenshots = [], servers = [];
|
||||
const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
|
||||
const near = (actual, expected, tolerance = 0.25) => Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance;
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' };
|
||||
await mkdir(output, { recursive: true });
|
||||
async function serve(directory) {
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://127.0.0.1');
|
||||
if (url.pathname === '/__p3_link_proof') {
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
return response.end('<!doctype html><title>P3 verifier navigation proof</title><link rel="icon" href="data:,"><p id="proof">Configured organization link reached</p>');
|
||||
}
|
||||
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
|
||||
const relative = path.relative(directory, file);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
|
||||
response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream');
|
||||
response.end(await readFile(file));
|
||||
} catch { response.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
servers.push(server);
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
function captureErrors(page, route) {
|
||||
page.on('pageerror', error => errors.push({ route, type: 'pageerror', message: error.message }));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push({ route, type: 'console', message: message.text() }); });
|
||||
}
|
||||
async function ready(page, mode = 'readOnly') {
|
||||
await page.waitForFunction(expectedMode => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
|
||||
return shadow?.querySelector(`.hm-orgchart-composition[data-display-mode="${expectedMode}"] [data-card-asset]`)
|
||||
&& !shadow.querySelector('.hm-orgchart-state')?.textContent
|
||||
&& [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet);
|
||||
}, mode, { timeout: 20000 });
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
await Promise.all([...shadow.querySelectorAll('img')].map(image => image.decode().catch(() => {})));
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
}
|
||||
async function measure(page) {
|
||||
return page.evaluate(() => {
|
||||
const host = document.querySelector('.hm-orgchart-host'), shadow = host.shadowRoot;
|
||||
// Chrome can serialize a zero letter-spacing as "normal". Compare against
|
||||
// an independently authored zero-spacing probe, not a guessed string.
|
||||
const zeroSpacingProbe = document.createElement('span');
|
||||
zeroSpacingProbe.textContent = '홍 길 동';
|
||||
zeroSpacingProbe.style.letterSpacing = '0';
|
||||
shadow.append(zeroSpacingProbe);
|
||||
const zeroSpacingSerialization = { authored: zeroSpacingProbe.style.letterSpacing, computed: getComputedStyle(zeroSpacingProbe).letterSpacing };
|
||||
zeroSpacingProbe.remove();
|
||||
const composition = shadow.querySelector('.hm-orgchart-composition'), stage = shadow.querySelector('.hm-orgchart-stage');
|
||||
const origin = stage.getBoundingClientRect(), scale = origin.width / stage.offsetWidth;
|
||||
const rect = element => {
|
||||
if (!element) return null;
|
||||
const box = element.getBoundingClientRect();
|
||||
return { x: (box.left - origin.left) / scale, y: (box.top - origin.top) / scale, width: box.width / scale, height: box.height / scale };
|
||||
};
|
||||
const screenRect = element => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return { x: box.x, y: box.y, width: box.width, height: box.height, right: box.right, bottom: box.bottom };
|
||||
};
|
||||
const dimensions = element => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY });
|
||||
const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? '';
|
||||
const overlaps = (a, b) => Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.75 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.75;
|
||||
const contained = (inner, outer, tolerance = 0.75) => inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.x + inner.width <= outer.x + outer.width + tolerance && inner.y + inner.height <= outer.y + outer.height + tolerance;
|
||||
const cssValues = (element, properties) => element ? Object.fromEntries(properties.map(name => [name, getComputedStyle(element)[name]])) : null;
|
||||
const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => {
|
||||
const node = card.closest('.node'), box = rect(card), name = card.querySelector('.hm-orgchart-person-name');
|
||||
const role = card.querySelector('.hm-orgchart-person-role'), badge = card.querySelector('.hm-orgchart-job-title');
|
||||
const row = card.querySelector('.hm-orgchart-position-row'), avatar = card.querySelector('.hm-orgchart-avatar');
|
||||
const textBoxes = [...card.querySelectorAll('.hm-orgchart-person-role, .hm-orgchart-person-name, .hm-orgchart-job-title')].map(element => {
|
||||
const range = document.createRange(); range.selectNodeContents(element);
|
||||
const envelopes = [...range.getClientRects()].map(rangeBox => ({ x: (rangeBox.x - origin.left) / scale, y: (rangeBox.y - origin.top) / scale, width: rangeBox.width / scale, height: rangeBox.height / scale }));
|
||||
return { className: element.className, box: rect(element), clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, envelopes };
|
||||
});
|
||||
const issues = textBoxes.flatMap(item => [
|
||||
...(item.scrollWidth > item.clientWidth + 1 ? [`${item.className}: horizontal text overflow`] : []),
|
||||
...(!contained(item.box, box) ? [`${item.className}: element outside card`] : []),
|
||||
...(item.envelopes.some(envelope => envelope.x < box.x - 0.75 || envelope.x + envelope.width > box.x + box.width + 0.75) ? [`${item.className}: text outside horizontal card bounds`] : []),
|
||||
]);
|
||||
const logo = card.querySelector('.hm-orgchart-affiliation-mark');
|
||||
const avatarBox = rect(avatar), logoBox = rect(logo);
|
||||
for (const item of textBoxes) {
|
||||
if (overlaps(item.box, avatarBox)) issues.push(`${item.className}: avatar element overlap`);
|
||||
if (logoBox && item.envelopes.some(envelope => overlaps(envelope, logoBox))) issues.push(`${item.className}: text envelope/logo overlap`);
|
||||
}
|
||||
return {
|
||||
id: card.dataset.employeeId, kind: card.dataset.cardKind, asset: card.dataset.cardAsset ?? null, size: card.dataset.cardAssetSize ?? null,
|
||||
organizationId: node?.dataset.organizationId ?? null, depth: node ? Number(node.dataset.organizationDepth ?? node.dataset.depth) : null,
|
||||
statusGroup: card.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? null, box,
|
||||
name: text(name), role: text(role), badge: text(badge), nameAccessible: name.getAttribute('aria-label'),
|
||||
photoState: card.dataset.photoState, avatarText: text(avatar), avatarBox, avatarStyle: cssValues(avatar, ['borderRadius', 'backgroundColor']),
|
||||
photoStyle: cssValues(avatar.querySelector('img'), ['objectFit']),
|
||||
rowGap: row ? rect(name).y - rect(row).y - rect(row).height : null,
|
||||
badgeGap: row && badge ? rect(badge).x - rect(role).x - rect(role).width : null,
|
||||
badgeInRow: !badge || !!badge.closest('.hm-orgchart-position-row'),
|
||||
badgeStyle: cssValues(badge, ['color', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderRadius', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'paddingLeft', 'paddingRight']),
|
||||
nameStyle: cssValues(name, ['fontSize', 'fontWeight', 'lineHeight']),
|
||||
roleStyle: cssValues(role, ['fontSize', 'fontWeight', 'lineHeight']),
|
||||
statusLabels: card.querySelectorAll('.hm-orgchart-status-label').length,
|
||||
keyboardRole: card.getAttribute('role'), tabIndex: card.tabIndex, issues, textBoxes,
|
||||
};
|
||||
});
|
||||
const headers = [...shadow.querySelectorAll('.node[data-organization-id] .hm-orgchart-organization-name')].map(header => {
|
||||
const node = header.closest('.node'), box = rect(header), first = node.querySelector('.hm-orgchart-employee');
|
||||
return { id: node.dataset.organizationId, depth: Number(node.dataset.organizationDepth ?? node.dataset.depth), text: text(header), box,
|
||||
gap: first ? rect(first).y - box.y - box.height : null,
|
||||
style: cssValues(header, ['backgroundImage', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'textAlign', 'padding', 'borderRadius', 'boxShadow']), link: header.querySelector('a')?.getAttribute('href') ?? null };
|
||||
});
|
||||
const pseudoRoutes = [...shadow.querySelectorAll('.hm-orgchart-engine .hierarchy, .hm-orgchart-engine .node')].flatMap(element => ['::before', '::after'].map(pseudo => ({ className: element.className, depth: element.dataset.depth ?? null, pseudo, content: getComputedStyle(element, pseudo).content, display: getComputedStyle(element, pseudo).display })));
|
||||
const overlay = shadow.querySelector('.hm-orgchart-workdesk-overlay');
|
||||
const connectors = [...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay path[data-connector]')].map(element => ({ id: element.dataset.connector, path: element.getAttribute('d') }));
|
||||
const arrows = [...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay image[data-arrow]')].map(element => ({ id: element.dataset.arrow, href: (element.getAttribute('href') ?? '').slice(0, 40), box: rect(element) }));
|
||||
const table = shadow.querySelector('.hm-orgchart-workdesk-position-table');
|
||||
const tableData = table ? {
|
||||
title: text(shadow.querySelector('.hm-orgchart-organization-totals-title')),
|
||||
header: [...table.querySelectorAll('thead th')].map(text),
|
||||
rows: [...table.querySelectorAll('tbody tr')].map(row => ({ id: row.dataset.organizationId, scope: row.dataset.countScope, values: [...row.children].map(text) })),
|
||||
total: [...table.querySelectorAll('tfoot tr > *')].map(text), box: rect(table),
|
||||
} : null;
|
||||
const hub = shadow.querySelector('.hm-orgchart-hub'), hubIcon = hub?.querySelector('.hm-orgchart-hub-icon'), hubLabel = hub?.querySelector('.hm-orgchart-hub-label');
|
||||
const bar = shadow.querySelector('.hm-orgchart-affiliation-bar');
|
||||
const barItems = [...shadow.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(item => ({ id: item.dataset.affiliationId ?? 'total', count: Number(item.dataset.employeeCount ?? item.dataset.totalCount), box: rect(item), logoLoaded: [...item.querySelectorAll('img')].every(image => image.complete && image.naturalWidth > 0) }));
|
||||
const modules = [...shadow.querySelectorAll('.hm-orgchart-organization-totals, .hm-orgchart-affiliation-bar, .hm-orgchart-hub')].map(element => ({ id: element.className, box: rect(element) }));
|
||||
const physical = [...cards.map(card => ({ id: `card:${card.id}:${card.statusGroup ?? 'tree'}`, box: card.box })), ...headers.map(header => ({ id: `header:${header.id}`, box: header.box }))];
|
||||
const collisions = [];
|
||||
for (const [index, a] of physical.entries()) for (const b of physical.slice(index + 1)) if (overlaps(a.box, b.box)) collisions.push({ a: a.id, b: b.id });
|
||||
const moduleCollisions = modules.flatMap(module => physical.filter(item => overlaps(module.box, item.box)).map(item => ({ module: module.id, item: item.id })));
|
||||
for (const [index, a] of modules.entries()) for (const b of modules.slice(index + 1)) if (overlaps(a.box, b.box)) moduleCollisions.push({ a: a.id, b: b.id });
|
||||
const outsideStage = [...physical, ...modules].filter(item => !contained(item.box, { x: 0, y: 0, width: stage.offsetWidth, height: stage.offsetHeight })).map(item => item.id);
|
||||
return {
|
||||
scale, recordedScale: composition.dataset.scale, baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight, zeroSpacingSerialization,
|
||||
screen: { stage: screenRect(stage), composition: screenRect(composition), host: screenRect(host) },
|
||||
background: { body: getComputedStyle(document.body).backgroundColor, composition: getComputedStyle(composition).backgroundColor, stage: getComputedStyle(stage).backgroundColor },
|
||||
document: dimensions(document.documentElement), composition: dimensions(composition), stage: dimensions(stage), host: dimensions(host),
|
||||
cards, headers, pseudoRoutes, connectors, arrows, overlayViewBox: overlay?.getAttribute('viewBox'),
|
||||
decorations: [...shadow.querySelectorAll('img[data-workdesk-asset]')].map(image => ({ id: image.dataset.workdeskAsset, loaded: image.complete && image.naturalWidth > 0 })),
|
||||
table: tableData, bar: bar ? { box: rect(bar), titleCount: bar.querySelectorAll('h1,h2,h3').length, items: barItems } : null,
|
||||
hub: { box: rect(hub), iconBox: rect(hubIcon), labelBox: rect(hubLabel), paths: hubIcon?.querySelectorAll('path').length ?? 0,
|
||||
gap: hubLabel && hubIcon ? rect(hubLabel).x - rect(hubIcon).x - rect(hubIcon).width : null,
|
||||
style: cssValues(hubLabel, ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']), text: text(hubLabel) },
|
||||
status: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(group => ({ status: group.dataset.workStatus, heading: text(group.querySelector('h3')) })),
|
||||
collisions, moduleCollisions, outsideStage, debugCount: shadow.querySelectorAll('.hm-orgchart-toggle, .hm-orgchart-kind, .hm-orgchart-domain-id, .hm-orgchart-node-count, .hm-orgchart-vacancy, .hm-orgchart-description, .hm-orgchart-policy').length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const preferredPositions = ['경영진', '수석', '책임', '선임', '연구'];
|
||||
const positions = [...preferredPositions.filter(position => fixture.employees.some(employee => employee.position === position)), ...new Set(fixture.employees.map(employee => employee.position).filter(position => !preferredPositions.includes(position)))];
|
||||
const organizationById = new Map(fixture.organizations.map(organization => [organization.id, organization]));
|
||||
const depthOf = id => { let depth = 0; while (organizationById.get(id)?.parentId) { id = organizationById.get(id).parentId; depth++; } return depth; };
|
||||
const majorRows = fixture.organizations.filter(organization => depthOf(organization.id) <= 1);
|
||||
const descendantsOf = id => { const ids = [id]; for (let index = 0; index < ids.length; index++) ids.push(...fixture.organizations.filter(organization => organization.parentId === ids[index]).map(organization => organization.id)); return ids; };
|
||||
const expectedRows = majorRows.map(organization => {
|
||||
const scope = organization.parentId === null && fixture.organizations.some(item => item.parentId === organization.id) ? 'direct' : 'inclusive';
|
||||
const ids = scope === 'direct' ? [organization.id] : descendantsOf(organization.id);
|
||||
const employees = fixture.employees.filter(employee => ids.includes(employee.organizationId));
|
||||
return { id: organization.id, scope, values: [organization.name, ...positions.map(position => String(employees.filter(employee => employee.position === position).length)), String(employees.length)] };
|
||||
}).filter(row => row.scope !== 'direct' || Number(row.values.at(-1)) > 0);
|
||||
const expectedTotal = ['계', ...positions.map(position => String(fixture.employees.filter(employee => employee.position === position).length)), String(fixture.employees.length)];
|
||||
function verify(value, label, viewport) {
|
||||
const assetCards = value.cards.filter(card => card.asset);
|
||||
const first = assetCards.filter(card => card.depth === 1 && card.kind === 'leader');
|
||||
const smaller = assetCards.filter(card => card.depth >= 2 || card.kind === 'employee');
|
||||
check(value.background.body === 'rgb(245, 248, 251)' && value.background.composition === 'rgb(245, 248, 251)' && value.background.stage === 'rgb(245, 248, 251)', `${label}: Full Composition background #F5F8FB`, value.background);
|
||||
check(value.pseudoRoutes.every(pseudo => ['none', 'normal'].includes(pseudo.content) || pseudo.display === 'none'), `${label}: every engine hierarchy/node pseudo connector is hidden`, value.pseudoRoutes.filter(pseudo => !['none', 'normal'].includes(pseudo.content) && pseudo.display !== 'none'));
|
||||
check(value.connectors.length === 5 && value.connectors.every(connector => connector.path) && value.arrows.length === 5 && value.overlayViewBox === '0 0 1920 914.5', `${label}: existing five Workdesk curve paths and five collaboration arrow assets retained`, { connectors: value.connectors, arrows: value.arrows.map(arrow => arrow.id) });
|
||||
check(value.decorations.length > 0 && value.decorations.every(image => image.loaded), `${label}: original Workdesk decoration assets remain loaded`, value.decorations);
|
||||
check(value.cards.every(card => card.name === '홍 길 동' && card.nameAccessible === '홍길동' && card.role === fixture.employees.find(employee => employee.id === card.id)?.position && card.badge === (fixture.employees.find(employee => employee.id === card.id)?.jobTitle ?? '')), `${label}: only names are spaced; positions and badges preserve raw strings`, value.cards.map(({ id, name, role, badge }) => ({ id, name, role, badge })));
|
||||
check(assetCards.every(card => near(card.rowGap, 0) && card.badgeInRow && (card.badgeGap === null || near(card.badgeGap, 4))), `${label}: role and badge share one row, gap4; position/name gap0`, assetCards.map(({ id, rowGap, badgeGap, badgeInRow }) => ({ id, rowGap, badgeGap, badgeInRow })));
|
||||
check(value.zeroSpacingSerialization.authored === '0px' && assetCards.filter(card => card.badge).every(card => {
|
||||
const small = card.size === 'employee', style = card.badgeStyle;
|
||||
return style.borderTopWidth === '1px' && style.borderRadius === '4px' && style.letterSpacing === value.zeroSpacingSerialization.computed && style.fontSize === (small ? '8.5px' : '9px') && style.fontWeight === (small ? '600' : '700') && style.lineHeight === (small ? '8.5px' : '9px') && style.paddingLeft === (small ? '2px' : '3px') && style.paddingRight === (small ? '2px' : '3px');
|
||||
}), `${label}: badge source border/radius/font restored`, { zeroSpacingSerialization: value.zeroSpacingSerialization, badges: assetCards.filter(card => card.badge).map(({ id, badgeStyle }) => ({ id, badgeStyle })) });
|
||||
const tagColors = { 'business-planning': 'rgb(37, 78, 126)', 'talent-growth': 'rgb(14, 103, 200)', 'integrated-system': 'rgb(110, 71, 160)', 'design-planning': 'rgb(6, 154, 149)', 'solution-sales': 'rgb(81, 139, 53)' };
|
||||
check(assetCards.filter(card => card.badge).every(card => card.badgeStyle.color === (tagColors[card.asset] ?? 'rgb(14, 84, 146)')), `${label}: organization-specific badge source colors`, assetCards.filter(card => card.badge).map(({ id, asset, badgeStyle }) => ({ id, asset, color: badgeStyle.color })));
|
||||
check(assetCards.filter(card => card.badge).every(card => {
|
||||
const color = tagColors[card.asset] ?? 'rgb(14, 84, 146)', alpha = card.asset in tagColors ? ['0.1', '0.26'] : ['0.08', '0.2'];
|
||||
const rgba = value => color.replace('rgb(', 'rgba(').replace(')', `, ${value})`);
|
||||
return card.badgeStyle.backgroundColor === rgba(alpha[0]) && card.badgeStyle.borderTopColor === rgba(alpha[1]);
|
||||
}), `${label}: badge source background and border tint`, assetCards.filter(card => card.badge).map(({ id, badgeStyle }) => ({ id, background: badgeStyle.backgroundColor, border: badgeStyle.borderTopColor })));
|
||||
check(value.cards.every(card => card.avatarStyle.borderRadius === '3.5px' && card.avatarText === '' && card.photoState === 'empty'), `${label}: no-photo state is empty rectangular slot with no circular avatar or copied photo`, value.cards.map(({ id, avatarStyle, avatarText, photoState }) => ({ id, avatarStyle, avatarText, photoState })));
|
||||
check(assetCards.some(card => card.kind === 'topLeader' && near(card.box.width, 146) && near(card.box.height, 74)) && first.length === 6 && first.every(card => near(card.box.width, 146) && near(card.box.height, 68)) && smaller.length > 0 && smaller.every(card => near(card.box.width, 108) && near(card.box.height, 52)), `${label}: approved representative/Depth1/deeper card sizes preserved`);
|
||||
check(value.headers.filter(header => header.gap !== null).every(header => near(header.gap, 8)), `${label}: every organization header-to-first-card gap8`, value.headers.map(({ id, depth, gap }) => ({ id, depth, gap })));
|
||||
check(value.headers.filter(header => header.depth >= 2).every(header => near(header.box.width, 114) && near(header.box.height, 30.4) && header.style.backgroundImage.startsWith('linear-gradient') && header.style.borderRadius === '7.1px' && header.style.textAlign === 'center'), `${label}: deeper group source header geometry and gradient`, value.headers.filter(header => header.depth >= 2));
|
||||
const headerGradients = { 'business-management': ['rgb(25, 77, 130)', 'rgb(29, 57, 92)'], 'business-planning': ['rgb(33, 100, 168)', 'rgb(37, 73, 117)'], 'talent-growth': ['rgb(15, 106, 206)', 'rgb(4, 74, 148)'], 'integrated-system': ['rgb(140, 103, 182)', 'rgb(93, 52, 148)'], 'design-planning': ['rgb(9, 167, 162)', 'rgb(0, 132, 127)'], 'solution-sales': ['rgb(90, 150, 60)', 'rgb(63, 118, 41)'] };
|
||||
check(value.headers.filter(header => header.depth >= 2).every(header => {
|
||||
let id = header.id; while (depthOf(id) > 1) id = organizationById.get(id).parentId;
|
||||
const expected = headerGradients[id], hr = id === 'talent-growth';
|
||||
return expected?.every(color => header.style.backgroundImage.includes(color)) && header.style.fontSize === (hr ? '18px' : '20px') && header.style.fontWeight === (hr ? '600' : '700') && header.style.lineHeight === (hr ? '16.9px' : '19.7px');
|
||||
}), `${label}: deeper headers inherit original parent color and source typography`, value.headers.filter(header => header.depth >= 2).map(({ id, style }) => ({ id, style })));
|
||||
check(value.hub.paths === 3 && near(value.hub.iconBox?.width, 20) && near(value.hub.iconBox?.height, 20) && near(value.hub.gap, 8) && value.hub.style.fontSize === '21px' && value.hub.style.fontWeight === '750' && value.hub.style.lineHeight === '24px', `${label}: original hub icon and source text metrics`, value.hub);
|
||||
check(value.hub.iconBox && value.hub.labelBox && [value.hub.iconBox, value.hub.labelBox].every(box => box.x >= value.hub.box.x - 0.5 && box.y >= value.hub.box.y - 0.5 && box.x + box.width <= value.hub.box.x + value.hub.box.width + 0.5 && box.y + box.height <= value.hub.box.y + value.hub.box.height + 0.5), `${label}: hub icon and label remain inside source pill`, value.hub);
|
||||
check(value.status.every(group => group.heading === (group.status === 'dispatched' ? '파견' : '휴직')) && value.cards.filter(card => card.statusGroup).every(card => card.statusLabels === 0), `${label}: compact status headers retained and below-name labels removed`, value.status);
|
||||
check(value.table?.title === '직위별 인원 현황 (팀별)' && JSON.stringify(value.table?.header) === JSON.stringify(['팀원', ...positions, '소계']) && JSON.stringify(value.table?.rows) === JSON.stringify(expectedRows) && JSON.stringify(value.table?.total) === JSON.stringify(expectedTotal) && fixture.employees.length === 41, `${label}: position matrix cross-counts original 41 employees`, { actual: value.table, expectedRows, expectedTotal });
|
||||
check(value.bar && value.bar.titleCount === 0 && near(value.bar.box.height, 55.4) && value.bar.items.every(item => near(item.box.y, value.bar.items[0].box.y)) && value.bar.items.every(item => item.logoLoaded) && value.bar.items.every(item => item.count === (item.id === 'total' ? fixture.employees.length : fixture.employees.filter(employee => employee.affiliationId === item.id).length)), `${label}: untitled one-row affiliation bar height55.4 and original counts/logos`, value.bar);
|
||||
check(value.cards.every(card => card.issues.length === 0), `${label}: card text has no horizontal overflow or element collisions`, value.cards.filter(card => card.issues.length).map(({ id, issues, textBoxes }) => ({ id, issues, textBoxes })));
|
||||
check(value.collisions.length === 0 && value.moduleCollisions.length === 0 && value.outsideStage.length === 0, `${label}: cards/headers/modules do not overlap or fall outside stage`, { collisions: value.collisions, modules: value.moduleCollisions, outside: value.outsideStage });
|
||||
check(value.document.scrollWidth <= value.document.clientWidth && value.document.scrollHeight <= value.document.clientHeight && value.screen.stage.right <= viewport.width + 1 && value.screen.stage.bottom <= viewport.height + 1 && value.screen.composition.width <= viewport.width && value.screen.composition.height >= value.screen.stage.height - 1, `${label}: proportionally fitted whole composition; no document scrolling or viewport clipping`, { scale: value.scale, baseWidth: value.baseWidth, baseHeight: value.baseHeight, document: value.document, screen: value.screen });
|
||||
check(value.debugCount === 0 && value.cards.every(card => card.keyboardRole === 'button' && card.tabIndex === 0), `${label}: view-only cards retain keyboard information lookup without edit/collapse UI`, { debugCount: value.debugCount });
|
||||
}
|
||||
|
||||
async function dialogs(page, label, viewport) {
|
||||
await page.setViewportSize(viewport); await ready(page);
|
||||
const targets = await page.evaluate(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
const selectors = [
|
||||
['representative', '.hm-orgchart-top-leaders .hm-orgchart-employee'],
|
||||
['depth1', '.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'],
|
||||
['depth2', '.node[data-organization-depth="2"] .hm-orgchart-employee[data-card-kind="leader"]'],
|
||||
['employee', '.hm-orgchart-engine .hm-orgchart-employee[data-card-kind="employee"]'],
|
||||
['dispatched', '.hm-orgchart-status-group[data-work-status="dispatched"] .hm-orgchart-employee'],
|
||||
['onLeave', '.hm-orgchart-status-group[data-work-status="onLeave"] .hm-orgchart-employee'],
|
||||
];
|
||||
return selectors.map(([kind, selector]) => ({ kind, selector, id: shadow.querySelector(selector)?.dataset.employeeId }));
|
||||
});
|
||||
for (const target of targets) {
|
||||
if (!target.id) { check(false, `${label}: ${target.kind} dialog target exists`); continue; }
|
||||
const trigger = page.locator(target.selector).first(), dialog = page.locator('dialog.hm-orgchart-employee-dialog[open]');
|
||||
await trigger.click(); await dialog.waitFor({ state: 'visible' });
|
||||
const value = await dialog.evaluate(element => {
|
||||
const rect = element.getBoundingClientRect(), shadow = element.getRootNode();
|
||||
const fields = [...element.querySelectorAll('dt')].map(term => [term.textContent, term.nextElementSibling.textContent]);
|
||||
return { employeeId: element.dataset.employeeId, fields, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom }, insideStage: !!element.closest('.hm-orgchart-stage'), transform: getComputedStyle(element).transform, ariaModal: element.getAttribute('aria-modal'), focus: shadow.activeElement?.className, editCount: element.querySelectorAll('input,select,textarea,[contenteditable]').length };
|
||||
});
|
||||
interactions.push({ route: label, viewport, target: target.kind, ...value });
|
||||
const employee = fixture.employees.find(employee => employee.id === target.id);
|
||||
const fields = Object.fromEntries(value.fields);
|
||||
check(value.employeeId === employee.id && fields['이름'] === '홍 길 동' && fields['직위'] === employee.position && fields['소속'] === organizationById.get(employee.organizationId)?.name && (!employee.jobTitle || fields['직책'] === employee.jobTitle) && value.fields.every(([field]) => ['이름', '직위', '소속', '직책', '관계사', '근무 상태'].includes(field)) && value.editCount === 0, `${label}/${viewport.width}: ${target.kind} dialog shows only real fixture fields`, value);
|
||||
check(!value.insideStage && value.transform === 'none' && value.ariaModal === 'true' && value.rect.width >= Math.min(300, viewport.width - 32) && value.rect.right <= viewport.width + 1 && value.rect.bottom <= viewport.height + 1 && value.rect.x >= 0 && value.rect.y >= 0, `${label}/${viewport.width}: ${target.kind} modal remains unscaled and unclipped`, value.rect);
|
||||
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
|
||||
const trapped = await dialog.evaluate(element => element.contains(element.getRootNode().activeElement));
|
||||
check(trapped && value.focus === 'hm-orgchart-employee-dialog-close', `${label}/${viewport.width}: ${target.kind} modal receives and traps keyboard focus`);
|
||||
await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' });
|
||||
check(await trigger.evaluate(element => element.getRootNode().activeElement === element), `${label}/${viewport.width}: ${target.kind} ESC restores triggering card focus`);
|
||||
await trigger.press('Enter'); await dialog.waitFor({ state: 'visible' });
|
||||
await dialog.locator('.hm-orgchart-employee-dialog-close').click(); await dialog.waitFor({ state: 'hidden' });
|
||||
check(await trigger.evaluate(element => element.getRootNode().activeElement === element), `${label}/${viewport.width}: ${target.kind} Enter opens; Close restores card focus`);
|
||||
}
|
||||
}
|
||||
|
||||
async function configure(page, transport, options) {
|
||||
if (transport === 'react') {
|
||||
await page.evaluate(({ links, callback }) => {
|
||||
if (typeof window.OrgChartP1React?.setOptions !== 'function') throw new Error('React P1 example does not expose public-prop setOptions seam');
|
||||
window.OrgChartP1React.setOptions({ organizationLinks: links, onEmployeeClick: callback ? (employee, context) => {
|
||||
window.__p3CallbackEvents.push({ employeeId: employee.id, organizationId: context.organization?.id, affiliationId: context.affiliation?.id, triggerId: context.trigger.dataset.employeeId, eventType: context.originalEvent.type });
|
||||
} : undefined });
|
||||
}, options);
|
||||
} else {
|
||||
await page.evaluate(async ({ links, callback }) => {
|
||||
const seam = window.OrgChartP1; seam.chart.destroy();
|
||||
seam.chart = seam.api.createOrgChart({ container: document.getElementById('chart-host'), dataSource: new seam.api.JsonOrgChartDataSource(seam.data), stylesheetUrls: seam.stylesheetUrls, displayMode: 'readOnly', organizationLinks: links,
|
||||
onEmployeeClick: callback ? (employee, context) => {
|
||||
window.__p3CallbackEvents.push({ employeeId: employee.id, organizationId: context.organization?.id, affiliationId: context.affiliation?.id, triggerId: context.trigger.dataset.employeeId, eventType: context.originalEvent.type });
|
||||
} : undefined });
|
||||
await seam.chart.mount();
|
||||
}, options);
|
||||
}
|
||||
await ready(page);
|
||||
}
|
||||
async function linksAndCallbacks(page, transport, routeUrl, targetUrl) {
|
||||
const major = fixture.organizations.find(organization => depthOf(organization.id) === 1);
|
||||
await page.evaluate(() => { window.__p3CallbackEvents = []; });
|
||||
await configure(page, transport, { links: { [major.id]: targetUrl }, callback: true });
|
||||
const info = await page.evaluate(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
return { links: [...shadow.querySelectorAll('.hm-orgchart-organization-link')].map(link => ({ href: link.href, role: link.tagName, tabIndex: link.tabIndex, id: link.closest('.node').dataset.organizationId })), ordinaryTitles: [...shadow.querySelectorAll('.node[data-organization-depth="1"] .hm-orgchart-organization-name')].filter(header => !header.querySelector('a')).length };
|
||||
});
|
||||
check(info.links.length === 1 && info.links[0].id === major.id && info.links[0].href === targetUrl && info.links[0].role === 'A' && info.links[0].tabIndex === 0 && info.ordinaryTitles === 5, `${transport}: only explicitly mapped 1Depth title is a native link`, info);
|
||||
const card = page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee');
|
||||
await card.click();
|
||||
await page.waitForFunction(() => window.__p3CallbackEvents.length === 1);
|
||||
const first = await page.evaluate(() => ({ callbacks: window.__p3CallbackEvents, openDialogs: document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('dialog[open]').length }));
|
||||
check(first.callbacks.length === 1 && first.openDialogs === 0 && first.callbacks[0].triggerId === first.callbacks[0].employeeId && first.callbacks[0].organizationId && first.callbacks[0].affiliationId, `${transport}: custom callback fires once with fixture/context and replaces default dialog`, first);
|
||||
await card.press('Enter'); await card.press('Space');
|
||||
const keyboard = await page.evaluate(() => window.__p3CallbackEvents);
|
||||
check(keyboard.length === 3 && keyboard.slice(1).every(event => event.eventType === 'keydown'), `${transport}: Enter/Space each activate callback once`, keyboard);
|
||||
await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.blur());
|
||||
let reached = false;
|
||||
for (let step = 0; step < 60 && !reached; step++) {
|
||||
await page.keyboard.press('Tab');
|
||||
reached = await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.classList.contains('hm-orgchart-organization-link') ?? false);
|
||||
}
|
||||
const linkFocus = await page.locator('.hm-orgchart-organization-link').evaluate(element => ({ matches: element.matches(':focus-visible'), outlineStyle: getComputedStyle(element).outlineStyle, outlineWidth: getComputedStyle(element).outlineWidth }));
|
||||
check(reached && linkFocus.matches && linkFocus.outlineStyle === 'solid' && parseFloat(linkFocus.outlineWidth) >= 2, `${transport}: real Tab reaches title link with visible focus`, linkFocus);
|
||||
check((await page.evaluate(() => window.__p3CallbackEvents)).length === 3, `${transport}: title focus does not activate employee callback`);
|
||||
await Promise.all([page.waitForURL(targetUrl), page.keyboard.press('Enter')]);
|
||||
check(await page.locator('#proof').textContent() === 'Configured organization link reached', `${transport}: native keyboard link reaches configured verifier URL`);
|
||||
await page.goto(routeUrl); await ready(page);
|
||||
await page.evaluate(() => { window.__p3CallbackEvents = []; });
|
||||
await configure(page, transport, { links: {}, callback: false });
|
||||
check(await page.locator('.hm-orgchart-organization-link').count() === 0, `${transport}: removing configured links restores ordinary titles`);
|
||||
await page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee').click();
|
||||
await page.locator('dialog[open]').waitFor({ state: 'visible' });
|
||||
check(true, `${transport}: clearing custom callback restores default Core dialog`);
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
function sameGeometry(a, b) {
|
||||
if (!a || !b || a.cards.length !== b.cards.length || a.headers.length !== b.headers.length) return false;
|
||||
const boxesEqual = (first, second) => ['x', 'y', 'width', 'height'].every(key => near(first[key], second[key], 0.4));
|
||||
return a.cards.every((card, index) => ['id', 'kind', 'size', 'asset', 'name', 'role', 'badge'].every(key => card[key] === b.cards[index][key]) && boxesEqual(card.box, b.cards[index].box))
|
||||
&& a.headers.every((header, index) => header.id === b.headers[index].id && boxesEqual(header.box, b.headers[index].box))
|
||||
&& JSON.stringify(a.connectors) === JSON.stringify(b.connectors)
|
||||
&& JSON.stringify(a.table && { ...a.table, box: undefined }) === JSON.stringify(b.table && { ...b.table, box: undefined })
|
||||
&& a.table && b.table && boxesEqual(a.table.box, b.table.box);
|
||||
}
|
||||
|
||||
let browser;
|
||||
try {
|
||||
const [htmlOrigin, reactOrigin] = await Promise.all([serve(path.join(root, 'examples/org-chart/dist')), serve(path.join(root, 'examples/org-chart-react/dist'))]);
|
||||
// The user identified the live 6041 Storybook; never silently verify a stale
|
||||
// storybook-static build from P2 instead of that requested running server.
|
||||
const storybookOrigin = process.env.ORGCHART_STORYBOOK_URL?.replace(/\/$/, '') ?? 'http://127.0.0.1:6041';
|
||||
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
|
||||
const context = await browser.newContext({ deviceScaleFactor: 1, reducedMotion: 'reduce' });
|
||||
const routes = [
|
||||
{ label: 'html', transport: 'html', url: `${htmlOrigin}/p1.html?mode=readOnly` },
|
||||
{ label: 'file-classic', transport: 'html', url: `${pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href}?mode=readOnly` },
|
||||
{ label: 'react', transport: 'react', url: `${reactOrigin}/p1.html?mode=readOnly` },
|
||||
{ label: 'storybook', transport: null, url: `${storybookOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story` },
|
||||
];
|
||||
for (const route of routes) {
|
||||
const page = await context.newPage(); captureErrors(page, route.label);
|
||||
try {
|
||||
await page.setViewportSize(viewports[0]); await page.goto(route.url); await ready(page);
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize(viewport); await ready(page);
|
||||
const value = await measure(page); measurements.push({ route: route.label, ...viewport, ...value });
|
||||
verify(value, `${route.label}/${viewport.width}×${viewport.height}`, viewport);
|
||||
if (viewport.width === 1920 || viewport.width === 390) {
|
||||
const filename = `${route.label}-${viewport.width}x${viewport.height}.png`;
|
||||
await page.screenshot({ path: path.join(output, filename), fullPage: true }); screenshots.push(filename);
|
||||
}
|
||||
}
|
||||
await dialogs(page, route.label, viewports[0]);
|
||||
await dialogs(page, route.label, viewports.at(-1));
|
||||
if (route.label === 'html' || route.label === 'react') {
|
||||
await page.setViewportSize(viewports[0]);
|
||||
await linksAndCallbacks(page, route.transport, route.url, `${route.label === 'html' ? htmlOrigin : reactOrigin}/__p3_link_proof`);
|
||||
}
|
||||
} catch (error) { check(false, `${route.label}: P3 browser verification completed`, { message: error.message, stack: error.stack }); }
|
||||
finally { await page.close(); }
|
||||
}
|
||||
for (const viewport of viewports) {
|
||||
const values = ['html', 'file-classic', 'react', 'storybook'].map(route => measurements.find(item => item.route === route && item.width === viewport.width));
|
||||
check(values.every(Boolean) && values.slice(1).every(value => sameGeometry(values[0], value)), `HTML/classic/React/Story shared Core geometry and matrix/${viewport.width}`, { routes: values.map(value => value?.route ?? 'missing'), basePixelTolerance: 0.4 });
|
||||
}
|
||||
const fitPage = await context.newPage(); captureErrors(fitPage, 'host-fit');
|
||||
await fitPage.setViewportSize({ width: 1920, height: 650 }); await fitPage.goto(routes[0].url); await ready(fitPage);
|
||||
const shortValue = await measure(fitPage); measurements.push({ route: 'short-height', width: 1920, height: 650, ...shortValue });
|
||||
check(shortValue.document.scrollHeight <= shortValue.document.clientHeight && shortValue.screen.stage.bottom <= 650 && shortValue.scale <= (650 - shortValue.screen.composition.y) / shortValue.baseHeight + 0.002, 'short1920×650: fit respects actual visible height without assuming host header', shortValue.screen);
|
||||
await fitPage.setViewportSize(viewports[0]);
|
||||
await fitPage.evaluate(() => { document.getElementById('chart-host').style.height = '400px'; }); await ready(fitPage);
|
||||
const fixed = await measure(fitPage); measurements.push({ route: 'fixed400-host', width: 1920, height: 1080, ...fixed });
|
||||
check(fixed.screen.stage.height <= 400.5 && fixed.screen.composition.height <= 400.5 && fixed.scale <= 400 / fixed.baseHeight + 0.002 && fixed.screen.stage.bottom <= fixed.screen.host.y + 400.5, 'explicit400px container: fit uses host height without clipping whole composition', { scale: fixed.scale, screen: fixed.screen });
|
||||
await fitPage.evaluate(() => {
|
||||
const host = document.getElementById('chart-host'); host.style.height = ''; host.style.maxHeight = '400px';
|
||||
}); await ready(fitPage);
|
||||
const maxHeight = await measure(fitPage); measurements.push({ route: 'max-height400-host', width: 1920, height: 1080, ...maxHeight });
|
||||
check(maxHeight.screen.stage.height <= 400.5 && maxHeight.screen.composition.height <= 400.5 && maxHeight.scale <= 400 / maxHeight.baseHeight + 0.002, 'max-height400px container: fit respects ancestor max-height', { scale: maxHeight.scale, screen: maxHeight.screen });
|
||||
await fitPage.evaluate(() => {
|
||||
const host = document.getElementById('chart-host'); host.style.maxHeight = ''; host.style.height = '400px'; host.style.boxSizing = 'border-box'; host.style.padding = '20px';
|
||||
const chartRoot = host.querySelector('.hm-orgchart-host'); const inner = document.createElement('div'); inner.style.padding = '12px'; inner.style.boxSizing = 'border-box';
|
||||
host.append(inner); inner.append(chartRoot);
|
||||
}); await ready(fitPage);
|
||||
const padded = await measure(fitPage); measurements.push({ route: 'nested-padding400-host', width: 1920, height: 1080, ...padded });
|
||||
check(padded.screen.stage.height <= 336.5 && padded.scale <= 336 / padded.baseHeight + 0.002 && padded.screen.stage.bottom <= padded.screen.host.y + 336.5, '400px border-box ancestor: fit subtracts20px outer and12px inner vertical padding', { scale: padded.scale, screen: padded.screen });
|
||||
await fitPage.close();
|
||||
|
||||
const index = await (await fetch(`${storybookOrigin}/index.json`)).json();
|
||||
const entries = Object.values(index.entries ?? {});
|
||||
const visible = entries.filter(entry => entry.title === 'OrgChart' && entry.type === 'story' && !entry.tags?.includes('internal'));
|
||||
check(JSON.stringify(visible.map(entry => entry.name).sort()) === JSON.stringify(['Logo · Personnel Summary', 'Department Colors', 'Full Composition'].sort()), 'Storybook index: exactly three user-facing OrgChart pages', visible.map(({ id, name, tags }) => ({ id, name, tags })));
|
||||
check(['orgchart--logo', 'orgchart--position-summary', 'orgchart--affiliation-summary'].every(id => entries.some(entry => entry.id === id && entry.tags?.includes('internal'))), 'Storybook index: independent component verification Stories retained');
|
||||
const combined = await context.newPage(); captureErrors(combined, 'combined-summary');
|
||||
await combined.goto(`${storybookOrigin}/iframe.html?id=orgchart--logo-personnel-summary&viewMode=story`);
|
||||
await combined.waitForFunction(() => [...document.querySelectorAll('.hm-orgchart-component-host')].filter(host => host.shadowRoot?.querySelector('[data-component]')).length >= 3);
|
||||
const combinedContent = await combined.evaluate(() => [...document.querySelectorAll('.hm-orgchart-component-host')].map(host => [...host.shadowRoot.querySelectorAll('[data-component]')].map(element => element.dataset.component)).flat());
|
||||
check(['Logo', 'PositionSummary', 'AffiliationSummary'].every(name => combinedContent.includes(name)), 'Storybook combined page renders all three existing Core components', combinedContent);
|
||||
await combined.close();
|
||||
const manager = await context.newPage(); captureErrors(manager, 'storybook-manager');
|
||||
await manager.goto(`${storybookOrigin}/?path=/story/orgchart--full-composition`);
|
||||
await manager.locator('a[href*="orgchart--full-composition"]').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||
const sidebar = await manager.evaluate(() => [...document.querySelectorAll('a[href*="/story/orgchart--"]')].filter(element => element.getBoundingClientRect().width && element.getBoundingClientRect().height).map(element => ({ name: element.textContent.trim(), href: element.getAttribute('href') })));
|
||||
check(sidebar.length === 3 && ['logo-personnel-summary', 'department-colors', 'full-composition'].every(id => sidebar.some(item => item.href.includes(`orgchart--${id}`))), 'Storybook manager: actual visible OrgChart sidebar contains only three pages', sidebar);
|
||||
await manager.close();
|
||||
|
||||
const interactivePage = await context.newPage(); captureErrors(interactivePage, 'interactive');
|
||||
await interactivePage.goto(`${htmlOrigin}/p1.html`); await ready(interactivePage, 'interactive');
|
||||
const preserved = await interactivePage.evaluate(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
return { toggles: shadow.querySelectorAll('.hm-orgchart-toggle').length, visibleRoutes: [...shadow.querySelectorAll('.hm-orgchart-engine .hierarchy')].some(element => !['none', 'normal'].includes(getComputedStyle(element, '::before').content) && getComputedStyle(element, '::before').display !== 'none'), statusLabels: shadow.querySelector('.hm-orgchart-status-group .hm-orgchart-status-label')?.textContent };
|
||||
});
|
||||
check(preserved.toggles > 0 && preserved.visibleRoutes && ['파견', '휴직'].includes(preserved.statusLabels), 'interactive view preserves existing collapse, hierarchy routes and status labels', preserved);
|
||||
await interactivePage.close();
|
||||
check(createHash('sha256').update(await readFile(fixturePath)).digest('hex') === fixtureHash, 'P3 verifier preserves original 41-person fixture bytes', { sha256: fixtureHash });
|
||||
check(errors.length === 0, 'P3 routes have no browser runtime/console errors', errors);
|
||||
} catch (error) { check(false, 'P3 verifier setup/completion', { message: error.message, stack: error.stack }); }
|
||||
finally {
|
||||
await writeFile(path.join(output, 'browser.json'), JSON.stringify({
|
||||
browser: browser?.version(), scope: 'P3 impacts only; P2/old suites and expectations are unchanged', fixtureSha256: fixtureHash,
|
||||
transportPolicy: 'HTTP ESM/React/Storybook and true file:// classic without file-access Chrome flags',
|
||||
testLinkPolicy: '/__p3_link_proof URLs exist only in the verifier-owned local HTTP servers; no actual product URL/data was invented',
|
||||
connectorRoutingInk: 'NOT VERIFIED automatically: inspect representative screenshots for curved route/ink intersections',
|
||||
textInkCollision: 'NOT VERIFIED automatically: confirmed gap0 may overlap font envelopes; measured element boxes and horizontal text limits are checked',
|
||||
originalPhotoPolicy: 'Workdesk has rectangular photo slots and no explicit missing-photo asset. Fictitious cards use transparent empty slots, no real employee photo copied.',
|
||||
results, errors, measurements, interactions, screenshots,
|
||||
}, null, 2));
|
||||
await browser?.close();
|
||||
for (const server of servers) await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
const failures = results.filter(result => result.status === 'FAIL');
|
||||
console.log(JSON.stringify({ output, checks: results.length, failureCount: failures.length, failures: failures.slice(0, 30).map(result => result.name), ...(failures.length > 30 ? { additionalFailures: failures.length - 30 } : {}) }, null, 2));
|
||||
if (failures.length) process.exitCode = 1;
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const consumer = path.join(root, 'test-results/org-chart-consumer');
|
||||
const packageRoot = path.join(consumer, 'node_modules/@hm-design/org-chart');
|
||||
await mkdir(packageRoot, { recursive: true });
|
||||
execFileSync('tar.exe', ['-xzf', path.join(root, 'test-results/hm-design-org-chart-0.1.0.tgz'), '-C', packageRoot, '--strip-components=1']);
|
||||
await writeFile(path.join(consumer, 'package.json'), JSON.stringify({name:'org-chart-isolated-consumer',private:true,type:'module'}));
|
||||
const manifest = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'));
|
||||
assert.deepEqual(manifest.dependencies, {orgchart:'6.0.0'});
|
||||
assert.equal(manifest.peerDependencies, undefined);
|
||||
const consumerRequire = createRequire(path.join(consumer, 'package.json'));
|
||||
for (const subpath of ['', '/style.css', '/engine.css', '/browser']) await readFile(consumerRequire.resolve(`@hm-design/org-chart${subpath}`));
|
||||
const module = await import(pathToFileURL(consumerRequire.resolve('@hm-design/org-chart')).href);
|
||||
assert.equal(typeof module.createOrgChart, 'function');
|
||||
assert.equal(typeof module.JsonOrgChartDataSource, 'function');
|
||||
const fixture = JSON.parse(await readFile(new URL('data-v1.json', import.meta.url), 'utf8'));
|
||||
assert.equal(module.validateOrgChartDataset(fixture).valid, true);
|
||||
assert.equal(module.calculateSummary(fixture).raw.total, 4);
|
||||
for (const file of ['index.js','orgchart.browser.js']) {
|
||||
const bundle = await readFile(path.join(packageRoot, 'dist', file), 'utf8');
|
||||
assert.ok(!/\b(?:import|export)\s+[^;\n]*?from\s*['"](?!\.)/.test(bundle));
|
||||
assert.ok(!/\bimport\s*\(\s*['"](?!\.)/.test(bundle));
|
||||
assert.ok(!/from\s*['"]react(?:-dom)?['"]/.test(bundle));
|
||||
}
|
||||
await writeFile(path.join(consumer, 'consumer.ts'), `import { createOrgChart, JsonOrgChartDataSource, type OrgChartDataset, type OrgChartDataSource } from '@hm-design/org-chart';
|
||||
declare const data: OrgChartDataset;
|
||||
const source: OrgChartDataSource = new JsonOrgChartDataSource(data);
|
||||
const handle = createOrgChart({dataSource:source,stylesheetUrls:{engine:'engine.css',core:'style.css'}});
|
||||
const stop = handle.subscribe(state=>{if(state.status==='ready') console.log(state.summary.raw.total);});
|
||||
stop(); handle.destroy();\n`);
|
||||
await writeFile(path.join(consumer, 'tsconfig.json'), JSON.stringify({compilerOptions:{target:'ES2022',module:'NodeNext',moduleResolution:'NodeNext',lib:['ES2022','DOM'],strict:true,noEmit:true,types:[],skipLibCheck:false},include:['consumer.ts']}));
|
||||
const require = createRequire(import.meta.url);
|
||||
const tscManifest = require.resolve('typescript/package.json');
|
||||
const typescript = JSON.parse(await readFile(tscManifest, 'utf8'));
|
||||
execFileSync(process.execPath, [path.resolve(path.dirname(tscManifest), typescript.bin.tsc), '-p', path.join(consumer,'tsconfig.json')], {stdio:'inherit'});
|
||||
const report = {status:'PASS',checks:['Tarball public ESM import without DOM','Public CSS/browser entries exist','Strict NodeNext public declarations without React types','No unresolved bare imports in browser/ESM bundles','Tarball validated fixture and summary','No React peer/runtime dependency'],consumer};
|
||||
await writeFile(path.join(consumer,'report.json'), JSON.stringify(report,null,2));
|
||||
console.log(JSON.stringify(report,null,2));
|
||||
@@ -0,0 +1,38 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href);
|
||||
const browser = await chromium.launch({ executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
await page.goto('http://127.0.0.1:6027/iframe.html?id=orgchart--full-composition&viewMode=story');
|
||||
await page.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot?.querySelector('.hm-orgchart-toggle'));
|
||||
const result = await page.evaluate(async () => {
|
||||
const root = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
await document.fonts.ready;
|
||||
const shell = root.querySelector('.hm-orgchart-shell');
|
||||
const read = (element, pseudo) => {
|
||||
const css = getComputedStyle(element, pseudo);
|
||||
return { background: css.backgroundColor, border: css.borderTopColor, borderWidth: css.borderTopWidth, outline: css.outlineColor, outlineWidth: css.outlineWidth, outlineStyle: css.outlineStyle, content: css.content };
|
||||
};
|
||||
const hierarchy = [...root.querySelectorAll('.hierarchy')].find(element => getComputedStyle(element, '::before').content !== 'none');
|
||||
const node = [...root.querySelectorAll('.node')].find(element => getComputedStyle(element, '::before').content !== 'none');
|
||||
const backgroundChain = [];
|
||||
for (let element = root.querySelector('.hm-orgchart-engine'); element; element = element.parentElement ?? element.getRootNode()?.host) {
|
||||
const css = getComputedStyle(element);
|
||||
backgroundChain.push({ className: element.className, tag: element.tagName, background: css.backgroundColor, image: css.backgroundImage });
|
||||
}
|
||||
return { canvas: getComputedStyle(shell).backgroundColor, viewport: read(root.querySelector('.hm-orgchart-viewport')),
|
||||
backgroundChain,
|
||||
hierarchy: hierarchy ? read(hierarchy, '::before') : null, node: node ? read(node, '::before') : null,
|
||||
toggle: read(root.querySelector('.hm-orgchart-toggle')), resolved: Object.fromEntries(['stroke-default', 'stroke-input-focus', 'bg-content', 'bg-surface'].map(key => [key, getComputedStyle(shell).getPropertyValue('--hm-orgchart-resolved-' + key).trim()])) };
|
||||
});
|
||||
await page.keyboard.press('Tab');
|
||||
const focused = await page.evaluate(() => {
|
||||
const root = document.querySelector('.hm-orgchart-host').shadowRoot;
|
||||
const viewport = root.querySelector('.hm-orgchart-viewport'); viewport.focus();
|
||||
const css = getComputedStyle(viewport);
|
||||
return { focusVisible: viewport.matches(':focus-visible'), outline: css.outlineColor, width: css.outlineWidth, style: css.outlineStyle };
|
||||
});
|
||||
await writeFile('test-results/org-chart-quality/boundaries.json', JSON.stringify({ ...result, focused, interpretation: 'Hierarchy connection lines convey parent-child relation; retain contrast REVIEW instead of automatically treating all lines as decorative. Button text identifies the control, frame decoration alone is not automatically SC 1.4.11 failure.' }, null, 2));
|
||||
console.log(JSON.stringify({ ...result, focused }));
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,183 @@
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
// Quality-only verification. No data/source/lifecycle regression suite is run.
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const output = path.join(root, 'test-results/org-chart-quality');
|
||||
await mkdir(output, { recursive: true });
|
||||
const { chromium } = await import(process.env.ORGCHART_PLAYWRIGHT_MODULE ? pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href : 'playwright');
|
||||
const results = [], errors = [], measurements = [], screenshots = [];
|
||||
const check = (value, name, detail) => results.push({ name, status: value ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) });
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.ttf': 'font/ttf' };
|
||||
async function serve(directory) {
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://127.0.0.1');
|
||||
const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname));
|
||||
const relative = path.relative(directory, file);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end();
|
||||
response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream');
|
||||
response.end(await readFile(file));
|
||||
} catch { response.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
return { server, origin: `http://127.0.0.1:${server.address().port}` };
|
||||
}
|
||||
function captureErrors(page, label) {
|
||||
page.on('pageerror', error => errors.push({ label, type: 'pageerror', message: error.message }));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push({ label, type: 'console', message: message.text() }); });
|
||||
}
|
||||
async function ready(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot;
|
||||
return shadow?.querySelector('.hm-orgchart-shell') && [...shadow.querySelectorAll('link')].every(link => !!link.sheet)
|
||||
&& !shadow.querySelector('.hm-orgchart-state')?.textContent.includes('불러오는');
|
||||
}, undefined, { timeout: 20000 });
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot;
|
||||
await Promise.all([...shadow.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(image => image.decode().catch(() => {})));
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
}
|
||||
async function measure(page) {
|
||||
return page.evaluate(() => {
|
||||
const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host');
|
||||
const shadow = host.shadowRoot;
|
||||
const rect = element => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return { x: box.x + scrollX, y: box.y + scrollY, width: box.width, height: box.height };
|
||||
};
|
||||
const ranges = element => {
|
||||
const range = document.createRange(); range.selectNodeContents(element);
|
||||
return [...range.getClientRects()].map(box => ({ x: box.x + scrollX, y: box.y + scrollY, width: box.width, height: box.height }));
|
||||
};
|
||||
const dimensions = element => element ? { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, overflowX: getComputedStyle(element).overflowX } : null;
|
||||
const visible = element => element.getBoundingClientRect().width > 0 && element.getBoundingClientRect().height > 0;
|
||||
const text = [...shadow.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-organization-name, .hm-orgchart-contrast-result, .hm-orgchart-affiliation-name')].filter(visible);
|
||||
const textIssues = text.filter(element => element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1).map(element => ({ className: element.className, text: element.textContent, box: rect(element) }));
|
||||
const styles = element => {
|
||||
const css = getComputedStyle(element);
|
||||
return { color: css.color, background: css.backgroundColor, gradient: css.backgroundImage, font: css.fontFamily, fontSize: parseFloat(css.fontSize), fontWeight: css.fontWeight, border: css.borderTopColor, radius: css.borderRadius };
|
||||
};
|
||||
const backgroundFor = element => {
|
||||
for (let current = element; current; current = current.parentElement) {
|
||||
const css = getComputedStyle(current);
|
||||
if (css.backgroundImage !== 'none') return { ...styles(current), box: rect(current) };
|
||||
if (css.backgroundColor !== 'rgba(0, 0, 0, 0)' && css.backgroundColor !== 'transparent') return { ...styles(current), box: rect(current) };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const selectors = '.hm-orgchart-organization-name, .hm-orgchart-hub, [data-contrast-target], .hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-job-title, .hm-orgchart-status-label, .hm-orgchart-summary-title, .hm-orgchart-position-table th, .hm-orgchart-position-table td, .hm-orgchart-toggle';
|
||||
const colors = [...shadow.querySelectorAll(selectors)].filter(visible).map(element => ({
|
||||
className: element.className, organizationId: element.closest('[data-organization-id]')?.dataset.organizationId,
|
||||
variant: element.closest('[data-group-variant]')?.dataset.groupVariant,
|
||||
target: element.dataset.contrastTarget ?? null,
|
||||
text: element.textContent.trim(), box: rect(element), ranges: ranges(element), ...styles(element), behind: backgroundFor(element),
|
||||
clip: element.closest('.hm-orgchart-viewport, .hm-orgchart-summary-scroll') ? rect(element.closest('.hm-orgchart-viewport, .hm-orgchart-summary-scroll')) : null,
|
||||
}));
|
||||
const logos = [...shadow.querySelectorAll('.hm-orgchart-affiliation-mark')].map(mark => {
|
||||
const image = mark.querySelector('img');
|
||||
return { id: mark.dataset.affiliationId, label: mark.getAttribute('aria-label'), box: rect(mark),
|
||||
available: mark.dataset.logoAvailable, fallbackVisibility: getComputedStyle(mark.querySelector('.hm-orgchart-affiliation-fallback')).visibility,
|
||||
loaded: !!image?.complete && image.naturalWidth > 0, naturalWidth: image?.naturalWidth, naturalHeight: image?.naturalHeight,
|
||||
fit: image ? getComputedStyle(image).objectFit : null, imageBackground: image ? getComputedStyle(image).backgroundColor : null,
|
||||
source: image?.src ?? null };
|
||||
});
|
||||
const cardStyles = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({ id: element.dataset.employeeId, kind: element.dataset.cardKind, ...styles(element) }));
|
||||
return { documentWidth: document.documentElement.scrollWidth, textIssues, viewport: dimensions(shadow.querySelector('.hm-orgchart-viewport')),
|
||||
summary: dimensions(shadow.querySelector('.hm-orgchart-summary-scroll')), logos, colors, cardStyles,
|
||||
rowResults: [...shadow.querySelectorAll('.hm-orgchart-contrast-result')].map(element => ({ text: element.textContent, ...element.dataset })),
|
||||
policy: [...shadow.querySelectorAll('.hm-orgchart-policy')].map(element => element.textContent),
|
||||
components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component),
|
||||
variables: [...shadow.querySelectorAll('[data-color-variable]')].map(element => ({ variable: element.dataset.colorVariable, property: element.dataset.cssProperty, computed: getComputedStyle(element).getPropertyValue(element.dataset.cssProperty), value: element.parentElement.querySelector('.hm-orgchart-color-value').textContent })),
|
||||
navigation: shadow.querySelectorAll('nav, .hm-dashboard-header, .hm-gnb, .hm-lnb, [data-slot=sidebar]').length };
|
||||
});
|
||||
}
|
||||
function compactSources(measurement) {
|
||||
for (const logo of measurement.logos) {
|
||||
logo.sha256 = logo.source?.startsWith('data:image/png;base64,') ? createHash('sha256').update(Buffer.from(logo.source.split(',')[1], 'base64')).digest('hex') : null;
|
||||
delete logo.source;
|
||||
}
|
||||
return measurement;
|
||||
}
|
||||
let browser;
|
||||
const servers = [];
|
||||
try {
|
||||
for (const directory of ['storybook-static', 'examples/org-chart/dist', 'examples/org-chart-react/dist']) servers.push(await serve(path.join(root, directory)));
|
||||
browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1, reducedMotion: 'reduce' });
|
||||
const canonical = ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition'];
|
||||
const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8'));
|
||||
const menu = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart') && !entry.tags?.includes('internal')).map(entry => entry.id).sort();
|
||||
check(JSON.stringify(menu) === JSON.stringify(canonical.map(name => `orgchart--${name}`).sort()), 'final build retains exactly five public OrgChart menu entries', menu);
|
||||
const internal = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title === 'OrgChart/Content');
|
||||
check(internal.length === 8 && internal.every(entry => entry.tags.includes('internal')), 'original eight verification Stories remain tagged internal');
|
||||
const sourceHashes = new Map();
|
||||
for (const id of ['hanmac', 'saman', 'jangheon', 'jangheon-industry', 'ptc', 'halla', 'baron']) sourceHashes.set(id, createHash('sha256').update(await readFile(path.join(root, `packages/org-chart/src/assets/affiliations/${id}.png`))).digest('hex'));
|
||||
for (const name of canonical) {
|
||||
const page = await context.newPage(); captureErrors(page, name);
|
||||
await page.goto(`${servers[0].origin}/iframe.html?id=orgchart--${name}&viewMode=story`); await ready(page);
|
||||
if (name.endsWith('summary')) check(await page.locator('body').innerText().then(text => text.includes('가상') && text.includes('운영')), `${name}: independent Story identifies virtual headcounts`);
|
||||
for (const width of name === 'full-composition' ? [390, 768, 1280, 1440] : [390, 1440]) {
|
||||
await page.setViewportSize({ width, height: 1000 }); await ready(page);
|
||||
const value = compactSources(await measure(page)); value.label = `storybook-${name}`; value.width = width;
|
||||
measurements.push(value);
|
||||
check(value.documentWidth <= width + 1 && value.textIssues.length === 0, `${name} ${width}px: changed content has no page overflow or clipped text`, value.textIssues);
|
||||
check(value.navigation === 0, `${name} ${width}px: no page Navigation added`);
|
||||
if (value.logos.length) check(value.logos.every(logo => logo.loaded && logo.fit === 'contain' && logo.naturalWidth === 473 && logo.naturalHeight === 473 && logo.sha256 === sourceHashes.get(logo.id) && logo.imageBackground === 'rgba(0, 0, 0, 0)' && logo.fallbackVisibility === 'hidden'), `${name} ${width}px: original transparent logos and ID mappings are preserved`);
|
||||
const file = `${value.label}-${width}.png`; value.screenshot = file;
|
||||
await page.screenshot({ path: path.join(output, file), fullPage: true }); screenshots.push(file);
|
||||
}
|
||||
if (name === 'department-colors') {
|
||||
const value = measurements.at(-1);
|
||||
check(value.variables.length === 30 && value.variables.every(item => item.value.includes('#')), 'Department Colors retains all six HEX and variable legends');
|
||||
check(value.rowResults.length >= 18 && value.rowResults.every(item => item.contrastStatus && !item.text.includes('NaN')), 'Department Colors exposes current and unapplied candidate contrast results', value.rowResults);
|
||||
}
|
||||
if (name === 'logo') {
|
||||
const result = await page.evaluate(async () => {
|
||||
const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot;
|
||||
const mark = shadow.querySelector('.hm-orgchart-affiliation-mark');
|
||||
const image = mark.querySelector('img');
|
||||
const fallback = mark.querySelector('.hm-orgchart-affiliation-fallback');
|
||||
const before = { label: mark.getAttribute('aria-label'), visibility: getComputedStyle(fallback).visibility };
|
||||
image.dispatchEvent(new Event('error'));
|
||||
return { before, after: { label: mark.getAttribute('aria-label'), visibility: getComputedStyle(fallback).visibility, available: mark.dataset.logoAvailable, text: fallback.textContent, imageRemoved: !mark.querySelector('img') } };
|
||||
});
|
||||
check(result.before.visibility === 'hidden' && result.after.visibility === 'visible' && result.after.available === 'false' && result.after.text && result.after.imageRemoved && result.after.label === result.before.label, 'transparent logo change retains text fallback and accessible company name on error', result);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
for (const [transport, server] of [['html', servers[1]], ['react', servers[2]]]) {
|
||||
const page = await context.newPage(); captureErrors(page, transport);
|
||||
await page.goto(`${server.origin}/p1.html`); await ready(page);
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
await page.setViewportSize({ width, height: 1000 }); await ready(page);
|
||||
const value = compactSources(await measure(page)); value.label = transport; value.width = width; measurements.push(value);
|
||||
check(value.documentWidth <= width + 1 && value.textIssues.length === 0 && value.navigation === 0, `${transport} ${width}px: logo style change preserves layout and text`);
|
||||
check(value.logos.every(logo => logo.loaded && logo.fit === 'contain' && logo.imageBackground === 'rgba(0, 0, 0, 0)' && logo.fallbackVisibility === 'hidden'), `${transport} ${width}px: transparent logo layer applied inside Shadow DOM`);
|
||||
const file = `${transport}-${width}.png`; value.screenshot = file;
|
||||
await page.screenshot({ path: path.join(output, file), fullPage: true }); screenshots.push(file);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
const html = measurements.find(item => item.label === 'html' && item.width === width);
|
||||
const react = measurements.find(item => item.label === 'react' && item.width === width);
|
||||
check(JSON.stringify(html.cardStyles) === JSON.stringify(react.cardStyles) && JSON.stringify(html.logos.map(logo => [logo.id, logo.sha256, logo.fit, logo.imageBackground])) === JSON.stringify(react.logos.map(logo => [logo.id, logo.sha256, logo.fit, logo.imageBackground])), `${width}px: HTML and React retain identical Core card colors and logo rendering`);
|
||||
}
|
||||
check(errors.length === 0, 'changed five Stories and theme-free HTML/React emit no console/runtime errors', errors);
|
||||
} catch (error) { results.push({ name: 'quality harness completion', status: 'FAIL', detail: error.stack }); }
|
||||
finally {
|
||||
await writeFile(path.join(output, 'browser.json'), JSON.stringify({ browser: browser?.version() ?? null, results, errors, measurements, screenshots,
|
||||
reused: ['Core22/22', 'HTML69/69', 'React79/79', 'P1 selector7/7', 'P1 successful196 plus console/menu4/4'],
|
||||
limits: ['No data/lifecycle regression suites repeated', 'Contrast model and pixel samples are reported separately; no all-screen WCAG certification'] }, null, 2));
|
||||
if (browser) await browser.close();
|
||||
for (const { server } of servers) await new Promise(resolve => server.close(resolve));
|
||||
const failed = results.filter(result => result.status === 'FAIL');
|
||||
if (failed.length) process.exitCode = 1;
|
||||
console.log(JSON.stringify({ passed: results.filter(result => result.status === 'PASS').length, failed, report: path.join(output, 'browser.json') }, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
// Browser tooling belongs to the verification environment, not the production package.
|
||||
const playwright = process.env.ORGCHART_PLAYWRIGHT_MODULE;
|
||||
const { chromium } = await import(playwright ? pathToFileURL(playwright).href : 'playwright');
|
||||
const example = fileURLToPath(new URL('./dist/', import.meta.url));
|
||||
const output = fileURLToPath(new URL('../../test-results/org-chart-browser/', import.meta.url));
|
||||
await mkdir(output, { recursive: true });
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.ttf': 'font/ttf' };
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const file = path.resolve(example, '.' + decodeURIComponent(url.pathname));
|
||||
const relative = path.relative(example, file);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; }
|
||||
const data = await readFile(file);
|
||||
response.setHeader('Content-Type', mime[path.extname(file)] || 'application/octet-stream');
|
||||
response.end(data);
|
||||
} catch { response.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', headless: true });
|
||||
const results = [], errors = [], metrics = [];
|
||||
const check = (condition, name, detail) => { results.push({ name, status: condition ? 'PASS' : 'FAIL', detail }); assert.ok(condition, `${name}: ${JSON.stringify(detail)}`); };
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 980 } });
|
||||
await context.addInitScript(() => {
|
||||
const NativeObserver = window.MutationObserver;
|
||||
const activeObservers = new Map();
|
||||
window.MutationObserver = class extends NativeObserver {
|
||||
observe(...args) { activeObservers.set(this, args[0]); return super.observe(...args); }
|
||||
disconnect() { activeObservers.delete(this); return super.disconnect(); }
|
||||
};
|
||||
const nativeAdd = EventTarget.prototype.addEventListener;
|
||||
const nativeRemove = EventTarget.prototype.removeEventListener;
|
||||
const listeners = [];
|
||||
const capture = options => typeof options === 'boolean' ? options : !!options?.capture;
|
||||
EventTarget.prototype.addEventListener = function(type, callback, options) {
|
||||
if ((this === window || this === document) && callback && !listeners.some(item => item.target === this && item.type === type && item.callback === callback && item.capture === capture(options))) {
|
||||
const item = { target: this, type, callback, capture: capture(options) };
|
||||
listeners.push(item);
|
||||
options?.signal?.addEventListener('abort', () => { const i = listeners.indexOf(item); if (i >= 0) listeners.splice(i, 1); }, { once: true });
|
||||
}
|
||||
return nativeAdd.call(this, type, callback, options);
|
||||
};
|
||||
EventTarget.prototype.removeEventListener = function(type, callback, options) {
|
||||
const index = listeners.findIndex(item => item.target === this && item.type === type && item.callback === callback && item.capture === capture(options));
|
||||
if (index >= 0) listeners.splice(index, 1);
|
||||
return nativeRemove.call(this, type, callback, options);
|
||||
};
|
||||
window.orgChartResources = () => ({
|
||||
observers: activeObservers.size,
|
||||
chartObservers: [...activeObservers.values()].filter(target=>target.getRootNode()?.host?.classList.contains('hm-orgchart-host')).length,
|
||||
globalListeners: listeners.map(item => `${item.target === document ? 'document' : 'window'}:${item.type}`).sort(),
|
||||
});
|
||||
});
|
||||
for (const [transport, url] of [['http-esm', `${origin}/index.html`], ['file-classic', pathToFileURL(path.join(example, 'classic.html')).href]]) {
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => errors.push({ transport, type: 'pageerror', message: error.message }));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push({ transport, type: 'console', message: message.text() }); });
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.orgChartReady);
|
||||
const state = await page.evaluate(() => {
|
||||
const root = window.OrgChartDemo.container.firstElementChild.shadowRoot;
|
||||
return { state: window.OrgChartDemo.chart.getState(), nodes: root.querySelectorAll('.node').length,
|
||||
topLeaders: [...root.querySelectorAll('.hm-orgchart-top-leaders [data-employee-id]')].map(card => card.dataset.employeeId),
|
||||
shownEmployees: [...root.querySelectorAll('.hm-orgchart-top-leaders [data-employee-id], .hm-orgchart-forest [data-employee-id]')].map(card => card.dataset.employeeId).sort() };
|
||||
});
|
||||
await page.screenshot({path:path.join(output, `${transport}-initial.png`),fullPage:true});
|
||||
check(state.state.status === 'ready', `${transport}: initial ready state`, state);
|
||||
// Composition contract: a root whose only direct employee is its leader shows that
|
||||
// leader as the standalone top card, and its child organizations become the engine roots.
|
||||
check(state.nodes === 2 && state.topLeaders.join() === 'emp-a' && state.shownEmployees.join() === 'emp-a,emp-b,emp-c,emp-d' && state.state.summary.raw.total === 4, `${transport}: mount + JSON + custom nodes`, state);
|
||||
check(state.state.aggregation.status === 'unavailable', `${transport}: no hidden business policy`);
|
||||
const baseline = await page.evaluate(() => window.orgChartResources());
|
||||
|
||||
for (const width of [1440, 1280, 768, 390]) {
|
||||
await page.setViewportSize({ width, height: 980 });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const measurement = await page.evaluate(() => {
|
||||
const root = window.OrgChartDemo.container.firstElementChild.shadowRoot;
|
||||
const node = root.querySelector('.hm-orgchart-organization');
|
||||
const style = getComputedStyle(node);
|
||||
const host = window.OrgChartDemo.container;
|
||||
const viewport = root.querySelector('.hm-orgchart-viewport');
|
||||
return {
|
||||
document: { width: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, height: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight },
|
||||
host: { width: host.clientWidth, scrollWidth: host.scrollWidth, height: host.clientHeight, scrollHeight: host.scrollHeight },
|
||||
viewport: viewport && { width: viewport.clientWidth, scrollWidth: viewport.scrollWidth, height: viewport.clientHeight, scrollHeight: viewport.scrollHeight, overflowX: getComputedStyle(viewport).overflowX },
|
||||
color: style.color, background: style.backgroundColor, font: style.fontFamily, radius: style.borderRadius,
|
||||
themeColor: getComputedStyle(host).getPropertyValue('--hm-sem-fg-primary').trim(),
|
||||
themeBackground: getComputedStyle(host).getPropertyValue('--hm-sem-bg-surface').trim(),
|
||||
themeRadius: getComputedStyle(host).getPropertyValue('--hm-radius-container').trim(),
|
||||
idWhiteSpace: getComputedStyle(root.querySelector('.hm-orgchart-domain-id')).whiteSpace,
|
||||
fontLoaded: document.fonts.check('13px "Noto Sans KR"'),
|
||||
connectorColor: getComputedStyle([...root.querySelectorAll('.node')][1], '::before').backgroundColor,
|
||||
borderColor: getComputedStyle(viewport).borderTopColor,
|
||||
parentCenter: (()=>{const n=root.querySelector('.node').getBoundingClientRect();return n.x+n.width/2;})(),
|
||||
childCenters: [...root.querySelectorAll('.node')].slice(1).map(element=>{const n=element.getBoundingClientRect();return n.x+n.width/2;}),
|
||||
};
|
||||
});
|
||||
metrics.push({ transport, width, ...measurement });
|
||||
check(measurement.document.scrollWidth <= width, `${transport}: ${width}px document overflow`, measurement);
|
||||
check(measurement.themeColor && measurement.background !== 'rgba(0, 0, 0, 0)' && measurement.font.includes('Noto Sans KR') && measurement.fontLoaded, `${transport}: ${width}px HM theme + font`, measurement);
|
||||
check(measurement.idWhiteSpace === 'nowrap', `${transport}: ${width}px one-line organization IDs`);
|
||||
check(measurement.connectorColor === measurement.borderColor && Math.abs(measurement.parentCenter - (measurement.childCenters[0]+measurement.childCenters[1])/2)<2, `${transport}: ${width}px themed + aligned hierarchy connectors`, measurement);
|
||||
await page.screenshot({ path: path.join(output, `${transport}-${width}.png`), fullPage: true });
|
||||
}
|
||||
// Shadow DOM buttons have native keyboard activation and retain focus on collapse.
|
||||
const toggle = page.locator('.hm-orgchart-toggle').first();
|
||||
await toggle.focus();
|
||||
await toggle.press('Enter');
|
||||
await page.waitForFunction(() => !window.OrgChartDemo.container.firstElementChild.shadowRoot.querySelector('.sliding'));
|
||||
check(await toggle.getAttribute('aria-expanded') === 'false', `${transport}: keyboard collapse`);
|
||||
check(await toggle.evaluate(element => element.getRootNode().activeElement === element), `${transport}: collapse focus retained`);
|
||||
await toggle.press('Space');
|
||||
await page.waitForFunction(() => !window.OrgChartDemo.container.firstElementChild.shadowRoot.querySelector('.sliding'));
|
||||
check(await toggle.getAttribute('aria-expanded') === 'true', `${transport}: keyboard expand`);
|
||||
await toggle.focus();
|
||||
await page.evaluate(() => window.OrgChartDemo.update());
|
||||
check(await page.evaluate(() => {
|
||||
const root = window.OrgChartDemo.container.firstElementChild.shadowRoot;
|
||||
return root.activeElement?.classList.contains('hm-orgchart-viewport');
|
||||
}), `${transport}: focus returns to viewport after regeneration`);
|
||||
const updated = await page.evaluate(() => window.OrgChartDemo.chart.getState());
|
||||
check(updated.status === 'ready' && updated.summary.raw.total === 5 && updated.summary.raw.byOrganization['team-a'].direct === 1 && updated.summary.raw.byOrganization['team-b'].direct === 3, `${transport}: update + movement + add + recalculated counts`, updated);
|
||||
await page.evaluate(() => window.OrgChartDemo.chart.reload());
|
||||
check(await page.evaluate(() => window.OrgChartDemo.chart.getState().summary.raw.total) === 4, `${transport}: reload source remains initial`);
|
||||
await page.evaluate(async () => {
|
||||
const demo = window.OrgChartDemo;
|
||||
const data = structuredClone(await demo.source().load());
|
||||
data.organizations.push({id:'depth-three',parentId:'team-a',name:'가상 하위 셀',type:'cell',leaderId:null,displayOrder:0});
|
||||
await demo.chart.update(data);
|
||||
});
|
||||
const rootToggle = page.locator('.hm-orgchart-toggle').first();
|
||||
await rootToggle.click();
|
||||
await page.waitForFunction(() => !window.OrgChartDemo.container.firstElementChild.shadowRoot.querySelector('.sliding'));
|
||||
await rootToggle.click();
|
||||
await page.waitForFunction(() => !window.OrgChartDemo.container.firstElementChild.shadowRoot.querySelector('.sliding'));
|
||||
const nested = await page.evaluate(() => {
|
||||
const root = window.OrgChartDemo.container.firstElementChild.shadowRoot;
|
||||
const node = [...root.querySelectorAll('.node')].find(node=>node.dataset.organizationId==='team-a');
|
||||
const grandchild = [...root.querySelectorAll('.node')].find(node=>node.dataset.organizationId==='depth-three');
|
||||
return {expanded:node.querySelector('.hm-orgchart-toggle').getAttribute('aria-expanded'),visible:grandchild.getBoundingClientRect().height > 0 && !grandchild.closest('.isCollapsedDescendant')};
|
||||
});
|
||||
check((nested.expanded === 'true') === nested.visible, `${transport}: depth-three descendant toggle matches visibility`, nested);
|
||||
await page.evaluate(() => window.OrgChartDemo.chart.reload());
|
||||
const lifecycle = await page.evaluate(async () => {
|
||||
const { api, chart, source, stylesheetUrls, container } = window.OrgChartDemo;
|
||||
const initial = await source().load();
|
||||
const changed = structuredClone(initial); changed.metadata.dataVersion = 'newest'; changed.employees.pop();
|
||||
let resolveOld, oldSignal;
|
||||
const oldSource = { load({signal}) { oldSignal = signal; return new Promise(resolve => { resolveOld = resolve; }); } };
|
||||
const pending = chart.reload(oldSource);
|
||||
await chart.update(changed);
|
||||
changed.employees.length = 0;
|
||||
resolveOld(initial); await pending;
|
||||
const race = { version: chart.getState().dataVersion, total: chart.getState().summary.raw.total, aborted: oldSignal.aborted };
|
||||
await chart.reload({load: async () => { throw new Error('private server detail'); }});
|
||||
const sourceError = chart.getState();
|
||||
const invalid = structuredClone(initial); invalid.organizations[0].name = '<img src=x onerror=alert(1)>';
|
||||
await chart.update(invalid);
|
||||
const validationError = chart.getState();
|
||||
await chart.reload(source());
|
||||
const sibling = document.createElement('p'); sibling.textContent = 'host sibling'; container.append(sibling);
|
||||
const secondHost = document.createElement('div'); secondHost.setAttribute('data-hm-theme', '');
|
||||
secondHost.style.setProperty('--hm-sem-fg-primary', getComputedStyle(container).getPropertyValue('--hm-sem-fg-secondary'));
|
||||
secondHost.style.setProperty('--hm-radius-container', '0px'); container.after(secondHost);
|
||||
const second = api.createOrgChart({container:secondHost, dataSource:source(), stylesheetUrls}); await second.mount();
|
||||
const firstRoot = container.firstElementChild.shadowRoot, secondRoot = secondHost.firstElementChild.shadowRoot;
|
||||
const firstIds = [...firstRoot.querySelectorAll('[id]')].map(element => element.id);
|
||||
const secondIds = [...secondRoot.querySelectorAll('[id]')].map(element => element.id);
|
||||
const scopes = { distinctIds: !firstIds.some(id => secondIds.includes(id)), firstColor:getComputedStyle(firstRoot.querySelector('.hm-orgchart-organization')).color, secondColor:getComputedStyle(secondRoot.querySelector('.hm-orgchart-organization')).color, secondRadius:getComputedStyle(secondRoot.querySelector('.hm-orgchart-organization')).borderRadius };
|
||||
chart.destroy(); chart.destroy();
|
||||
const destroy = { remaining:container.children.length, siblingPreserved:sibling.isConnected, secondReady:second.getState().status === 'ready' };
|
||||
second.destroy(); secondHost.remove(); sibling.remove();
|
||||
for (let i = 0; i < 5; i++) { const next = api.createOrgChart({container, dataSource:source(), stylesheetUrls}); await next.mount(); await next.reload(); next.destroy(); }
|
||||
const resources = window.orgChartResources();
|
||||
const reentrantHost = document.createElement('div'); container.after(reentrantHost);
|
||||
let reentrant;
|
||||
reentrant = api.createOrgChart({container:reentrantHost,stylesheetUrls,dataSource:source(),onStateChange(state){if(state.status==='loading')reentrant.destroy();}});
|
||||
await reentrant.mount();
|
||||
const reentrantDestroy = reentrantHost.children.length===0 && reentrant.getState().status==='destroyed'; reentrantHost.remove();
|
||||
const pendingHost = document.createElement('div'); container.after(pendingHost);
|
||||
let resolvePending, signalPending, callbackCount = 0;
|
||||
const destroyed = api.createOrgChart({container:pendingHost,stylesheetUrls,dataSource:{load({signal}){signalPending=signal;return new Promise(resolve=>{resolvePending=resolve;});}},onStateChange(){callbackCount++;}});
|
||||
const mounting = destroyed.mount();
|
||||
// Allow stylesheet loading before the source is entered.
|
||||
while (!resolvePending) await new Promise(resolve=>setTimeout(resolve,0));
|
||||
destroyed.destroy(); const atDestroy = callbackCount; resolvePending(initial); await mounting;
|
||||
const late = {children:pendingHost.children.length,aborted:signalPending.aborted,callbacksAfterDestroy:callbackCount-atDestroy}; pendingHost.remove();
|
||||
await window.OrgChartDemo.create();
|
||||
return {race,sourceError,validationError,scopes,destroy,resources,late,reentrantDestroy};
|
||||
});
|
||||
check(lifecycle.race.version === 'newest' && lifecycle.race.total === 3 && lifecycle.race.aborted, `${transport}: stale response aborted + immutable update`, lifecycle.race);
|
||||
check(lifecycle.sourceError.status === 'error' && !JSON.stringify(lifecycle.sourceError).includes('private server detail'), `${transport}: sanitized loading failure`, lifecycle.sourceError);
|
||||
check(lifecycle.validationError.status === 'error' && lifecycle.validationError.issues.some(issue=>issue.code==='UNSAFE_INPUT'), `${transport}: validation blocks engine payload`, lifecycle.validationError);
|
||||
check(lifecycle.scopes.distinctIds && lifecycle.scopes.firstColor !== lifecycle.scopes.secondColor && lifecycle.scopes.secondRadius === '0px', `${transport}: multiple instances + scoped theme`, lifecycle.scopes);
|
||||
check(lifecycle.destroy.remaining === 1 && lifecycle.destroy.siblingPreserved && lifecycle.destroy.secondReady, `${transport}: destroy preserves host sibling + other instance`, lifecycle.destroy);
|
||||
check(lifecycle.resources.chartObservers === 0 && lifecycle.resources.observers === baseline.observers && JSON.stringify(lifecycle.resources.globalListeners) === JSON.stringify(baseline.globalListeners), `${transport}: repeated lifecycle resource stability`, { baseline, after:lifecycle.resources });
|
||||
check(lifecycle.late.children === 0 && lifecycle.late.aborted && lifecycle.late.callbacksAfterDestroy === 0, `${transport}: destroy suppresses late callback`, lifecycle.late);
|
||||
check(lifecycle.reentrantDestroy, `${transport}: reentrant loading callback destroy`);
|
||||
await page.close();
|
||||
}
|
||||
check(errors.length === 0, 'No browser runtime/console errors', errors);
|
||||
} catch (error) {
|
||||
results.push({ name: 'Harness completion', status: 'FAIL', detail: error.stack });
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await writeFile(path.join(output, 'report.json'), JSON.stringify({ browser:browser.version(), results, errors, metrics, limits:['Full heap/GC recovery not verified','Screen reader announcement not verified','No visual reference supplied','Advanced responsive layout out of scope'] }, null, 2));
|
||||
await browser.close(); await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
console.log(JSON.stringify({ passed:results.filter(row=>row.status==='PASS').length, failed:results.filter(row=>row.status==='FAIL'), report:path.join(output,'report.json') }, null, 2));
|
||||
@@ -12,6 +12,8 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@hm-design/center-graphic": "*",
|
||||
"@hm-design/org-chart": "*",
|
||||
"@hm-design/org-chart-react": "*",
|
||||
"@hm-design/ui": "*",
|
||||
"lucide-react": "^1.49.0",
|
||||
"react": "19.3.0",
|
||||
@@ -436,7 +438,6 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1209,6 +1210,14 @@
|
||||
"resolved": "packages/center-graphic",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hm-design/org-chart": {
|
||||
"resolved": "packages/org-chart",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hm-design/org-chart-react": {
|
||||
"resolved": "packages/org-chart-react",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hm-design/ui": {
|
||||
"resolved": "packages/ui",
|
||||
"link": true
|
||||
@@ -4625,6 +4634,19 @@
|
||||
"undici-types": "~7.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
@@ -4652,6 +4674,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/validate-npm-package-name": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@@ -5248,6 +5277,15 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.27",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz",
|
||||
@@ -5448,6 +5486,26 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
@@ -5697,6 +5755,21 @@
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
@@ -5780,6 +5853,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
@@ -5995,6 +6077,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.16",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
|
||||
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dot-prop": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
|
||||
@@ -6427,6 +6519,17 @@
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
@@ -6472,6 +6575,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/figures": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
|
||||
@@ -6791,6 +6900,19 @@
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -6883,6 +7005,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz",
|
||||
@@ -7178,6 +7306,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-digger": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-digger/-/json-digger-2.0.2.tgz",
|
||||
"integrity": "sha512-d8Zq9WXlhfkzlDTgSa5B5C/pmR+NVaUVNtBm3tFVQhwAnlBn8AdNGj5fS7DuD61lA9Enn+Fq1sADYg1vCVXDXw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-parse-even-better-errors": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||
@@ -7232,6 +7366,23 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
|
||||
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
@@ -8034,6 +8185,17 @@
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/orgchart": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/orgchart/-/orgchart-6.0.0.tgz",
|
||||
"integrity": "sha512-ykRCXH3gnOB4Y80RN/ifEviZvFj1AMFSmTgTlPeLzcP1aNIUQcq+GyyO1j65hDIpkpSOhl1hIyC+7GrHIdcxmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"html2canvas": "^1.4.1",
|
||||
"json-digger": "^2.0.2",
|
||||
"jspdf": "^4.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/oxc-parser": {
|
||||
"version": "0.127.0",
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz",
|
||||
@@ -8142,6 +8304,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -8269,6 +8447,13 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -8556,6 +8741,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||
@@ -8795,6 +8990,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -8881,6 +9083,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
|
||||
@@ -9337,6 +9549,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -9562,6 +9784,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/systeminformation": {
|
||||
"version": "5.33.15",
|
||||
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.15.tgz",
|
||||
@@ -9609,6 +9841,15 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -9948,6 +10189,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/validate-npm-package-name": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
|
||||
@@ -10445,6 +10695,23 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"packages/org-chart": {
|
||||
"name": "@hm-design/org-chart",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"orgchart": "6.0.0"
|
||||
}
|
||||
},
|
||||
"packages/org-chart-react": {
|
||||
"name": "@hm-design/org-chart-react",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@hm-design/org-chart": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@hm-design/ui",
|
||||
"version": "0.1.0",
|
||||
|
||||
@@ -24,9 +24,22 @@
|
||||
"typecheck": "tsc -b && npm run typecheck --workspaces --if-present",
|
||||
"build:ui": "npm run build -w @hm-design/ui",
|
||||
"build:center-graphic": "npm run build -w @hm-design/center-graphic",
|
||||
"build:org-chart": "npm run build -w @hm-design/org-chart",
|
||||
"typecheck:org-chart": "npm run typecheck -w @hm-design/org-chart",
|
||||
"test:org-chart": "npm run test -w @hm-design/org-chart",
|
||||
"build:org-chart-example": "npm run build:org-chart && node examples/org-chart/prepare.mjs",
|
||||
"dev:org-chart-example": "node examples/org-chart/serve.mjs",
|
||||
"test:org-chart-browser": "node examples/org-chart/verify.mjs",
|
||||
"build:org-chart-react": "npm run build -w @hm-design/org-chart-react",
|
||||
"typecheck:org-chart-react": "npm run typecheck -w @hm-design/org-chart-react",
|
||||
"build:org-chart-react-example": "vite build --config examples/org-chart-react/vite.config.ts",
|
||||
"dev:org-chart-react-example": "vite --config examples/org-chart-react/vite.config.ts",
|
||||
"test:org-chart-react-browser": "node examples/org-chart-react/verify.mjs",
|
||||
"build:packages": "npm run build --workspaces --if-present"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hm-design/org-chart": "*",
|
||||
"@hm-design/org-chart-react": "*",
|
||||
"@hm-design/center-graphic": "*",
|
||||
"@hm-design/ui": "*",
|
||||
"lucide-react": "^1.49.0",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# @hm-design/org-chart-react v0.1
|
||||
|
||||
React 19 lifecycle adapter for `@hm-design/org-chart@0.1.0`. The wrapper renders one empty host container and delegates all organization DOM, engine work, validation, summaries, interactions and Shadow DOM styles to the existing Vanilla Core. It has no navigation, page layout, fetch client or separate node design.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { useMemo } from 'react';
|
||||
import { JsonOrgChartDataSource } from '@hm-design/org-chart';
|
||||
import { OrgChart } from '@hm-design/org-chart-react';
|
||||
import type { OrgChartDataset, OrgChartState } from '@hm-design/org-chart-react';
|
||||
import engineCssUrl from '@hm-design/org-chart/engine.css?url';
|
||||
import coreCssUrl from '@hm-design/org-chart/style.css?url';
|
||||
// Core P1 supplies standalone OrgChart design; no HM Theme/font import needed.
|
||||
|
||||
const stylesheetUrls = { engine: engineCssUrl, core: coreCssUrl };
|
||||
|
||||
export function OrganizationContent({ data }: { data: OrgChartDataset }) {
|
||||
const dataSource = useMemo(() => new JsonOrgChartDataSource(data), [data]);
|
||||
const onStateChange = (state: OrgChartState) => {
|
||||
// Data/source/validation/style failures are state.status === 'error'.
|
||||
// A ready state contains the Core's shared summary and dataVersion.
|
||||
void state;
|
||||
};
|
||||
return (
|
||||
<OrgChart
|
||||
dataSource={dataSource}
|
||||
stylesheetUrls={stylesheetUrls}
|
||||
onStateChange={onStateChange}
|
||||
onLifecycleError={error => { console.error('OrgChart lifecycle failed', error); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `?url` imports above are Vite asset imports, not a universal browser feature. Other hosts resolve the two Core public CSS entries to actual deployment URLs. Importing CSS only into the document does not style the Core's Shadow DOM. Engine CSS is linked first, then standalone Core CSS. P1 owns its colors, typography and geometry without depending on HM Theme. Set public `--hm-orgchart-*` properties on the wrapper container to override only this instance. No HM common variables are redefined. The wrapper does not depend on `@hm-design/ui`; P3 adds lookup Props without replacing the Core renderer. For independent Tree/Summary integration, use the [Core factories](../../docs/org-chart/standalone-components-p1.md) in a ref/effect without copying their UI.
|
||||
|
||||
## Public API
|
||||
|
||||
Named runtime export: `OrgChart`. Type exports: `OrgChartProps` and the Core's public types, reexported as types only. Runtime helpers such as `JsonOrgChartDataSource` remain available from `@hm-design/org-chart`.
|
||||
|
||||
`OrgChartProps` reuses `Omit<OrgChartOptions, 'container' | 'onStateChange'>`:
|
||||
|
||||
- Required `dataSource`: the same Core `OrgChartDataSource` interface, `load(options?: { signal?: AbortSignal }): Promise<unknown>`.
|
||||
- Required `stylesheetUrls`: `{ engine: string; core: string }`, the exact Core asset contract.
|
||||
- Optional `displayMode`: `'interactive'` (default) or `'readOnly'`, passed to the Core composition. Changing it recreates the Core handle.
|
||||
- Optional `organizationLinks`: existing organization-ID-to-URL map, applied only to original 1Depth titles. Missing/blank values retain plain titles. Core validates HTTP(S)/relative URLs and rejects invalid URLs, other schemes and credentials. Treat the map as immutable and memoize it; a new map recreates the handle.
|
||||
- Optional `onEmployeeClick(employee, context)`: host-owned information UI, replacing the Core's built-in popup. `context` contains the existing `organization`, `affiliation`, focusable `trigger` card and `originalEvent`. It works in both modes and receives untouched Employee data.
|
||||
- Optional `aggregationPolicy`: the Core's `ApprovedAggregationPolicy`.
|
||||
- Optional `className` and `style`: applied only to React's outer container.
|
||||
- Optional `onStateChange(state: OrgChartState)`: latest committed callback receives actual Core state changes.
|
||||
- Optional `onLifecycleError(error: unknown)`: reports unexpected Core creation or rejected mount/reload calls and unexpected cleanup errors. Controlled data, source, validation, rendering and stylesheet failures use the Core's `error` state instead.
|
||||
|
||||
The container is owned by React; its dedicated children are owned by Core. There are no React children, imperative handle, title, employee or engine manipulation Props. Titles, descriptions, presentation groups, organization order and metadata use the common data contract. An omitted lifecycle error callback leaves unexpected operation errors handled but does not produce a second error UI or console log. Consumer state callback errors follow the Core's isolation behavior; lifecycle error callback exceptions are also contained.
|
||||
|
||||
Without `onEmployeeClick`, all employee cards open the same Core read-only information dialog on click, Enter or Space. It uses only existing employee/organization/affiliation fields and omits missing values. readOnly names match the displayed `홍 길 동` card while source data remains unchanged. The native modal sits outside the scaled Stage and supports Close, ESC, focus trapping and return to the card. Group-link events remain separate; a visible name tooltip closes before popup activation. A custom callback owns its popup's accessibility and cleanup.
|
||||
|
||||
```tsx
|
||||
<OrgChart
|
||||
dataSource={dataSource}
|
||||
stylesheetUrls={stylesheetUrls}
|
||||
displayMode="readOnly"
|
||||
organizationLinks={configuredOrganizationLinks}
|
||||
onEmployeeClick={(employee, context) => openEmployeeInformation(employee, context)}
|
||||
/>
|
||||
```
|
||||
|
||||
Both map and popup function above are supplied by the host; no real destination URL or employee information is embedded in the package. Omit the callback for the default popup.
|
||||
|
||||
## Lifecycle and updates
|
||||
|
||||
Mount creates one new Core handle, subscribes to its state and calls `mount(container)`. Mount already loads the source; the wrapper does not perform an additional initial reload. The wrapper does not synthesize `idle`, `destroyed` or `empty` callbacks. Current Core emits `loading`, then `ready`/`error`; employees `[]` is ready, organizations `[]` is error. An `empty` type member does not establish a supported Empty State.
|
||||
|
||||
A new `dataSource` object triggers the existing handle's `reload(nextSource)`. This starts Core's latest revision and aborts or ignores older responses. Updating fields inside the same source object is not a reload signal. `JsonOrgChartDataSource` captures inline input at its construction; create a new source for a new immutable snapshot. Memoize sources rather than constructing one on every render.
|
||||
|
||||
Changing callback identity updates the latest committed callback without reload or recreation. Adding/removing `onEmployeeClick` switches built-in/custom popup ownership and recreates the handle. Abandoned concurrent renders do not update callbacks. Policy or `organizationLinks` identity changes, displayMode changes, or either stylesheet URL string value changes dispose the previous handle and create a new one because the Core offers no configuration setter. Replacing the stylesheet URL object with the same two strings does not recreate. Treat policy and link-map objects as immutable and memoize them; changing an object internally is not a configuration update signal.
|
||||
|
||||
Cleanup invalidates the wrapper generation, unsubscribes, calls Core's terminal `destroy()` and clears its handle reference. React Strict Mode's setup → cleanup → setup uses a fresh handle. Old generations cannot deliver state/lifecycle callbacks to the new instance; superseded operation rejection handlers are also ignored. Cleanup runs before replacement, leaving at most one active Core per wrapper container. The wrapper does not repeat engine disposal or DOM removal itself.
|
||||
|
||||
Core regeneration resets expansion/scroll state according to the existing Core contract. Source errors can recover by replacing the source; Core validation/summary/node behavior remains identical to Vanilla. Invalid runtime Props that prevent Core construction are lifecycle usage errors: fix them and remount the component (for example by changing its React key). No rendered fallback silently replaces an invalid configuration.
|
||||
|
||||
Committed callback publication, Core ownership and source updates use layout effects in that order. Cleanup/reload runs synchronously with React commit, so a pending old response cannot notify in a gap before passive cleanup. Server rendering produces only the outer container; SSR/hydration remains outside the verified scope.
|
||||
|
||||
## Package boundary and verification limits
|
||||
|
||||
The build emits ESM `dist/index.js` plus TypeScript declarations, preserves the `"use client"` boundary and leaves React (including `react/jsx-runtime`) and `@hm-design/org-chart` external. React is a `^19.0.0` peer, matching the repository's existing React package convention; Core `0.1.0` is a runtime dependency. React DOM is supplied by the consuming app and is not imported by this package. Core must be built before wrapper typecheck/build in this workspace because dependency resolution uses its public declarations.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
npm run typecheck --workspace @hm-design/org-chart-react
|
||||
npm run build --workspace @hm-design/org-chart-react
|
||||
```
|
||||
|
||||
Storybook is one React host, not proof of every consumer deployment. This adapter does not add pan/zoom, advanced mobile reflow, additional relation overlays or reserved Core presentation sections. SSR/hydration, full screen reader behavior, complete heap/GC recovery, Theme-less rendering, Workdesk reference visual approval and production consumers require their own evidence. Public ESM import without a DOM does not establish SSR/hydration support. Consult the final integration report for the actual typecheck/build/browser/consumer results.
|
||||
@@ -0,0 +1,41 @@
|
||||
import { build } from 'vite';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = fileURLToPath(new URL('./', import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const manifest = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
const externals = Object.keys({ ...manifest.dependencies, ...manifest.peerDependencies });
|
||||
|
||||
await build({
|
||||
configFile: false,
|
||||
root,
|
||||
publicDir: false,
|
||||
build: {
|
||||
target: 'es2022',
|
||||
outDir: 'dist',
|
||||
minify: false,
|
||||
lib: { entry: path.join(root, 'src/index.ts'), formats: ['es'], fileName: () => 'index.js' },
|
||||
rolldownOptions: {
|
||||
external: id => externals.some(dependency => id === dependency || id.startsWith(`${dependency}/`)),
|
||||
onwarn(warning, warn) {
|
||||
// Restore the client boundary on the final public entry below.
|
||||
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return;
|
||||
warn(warning);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const typescriptManifest = require.resolve('typescript/package.json');
|
||||
const typescript = JSON.parse(await readFile(typescriptManifest, 'utf8'));
|
||||
execFileSync(process.execPath, [
|
||||
path.resolve(path.dirname(typescriptManifest), typescript.bin.tsc),
|
||||
'-p', path.join(root, 'tsconfig.build.json'),
|
||||
], { stdio: 'inherit' });
|
||||
|
||||
const entry = path.join(root, 'dist/index.js');
|
||||
await writeFile(entry, `"use client";\n${await readFile(entry, 'utf8')}`);
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@hm-design/org-chart-react",
|
||||
"version": "0.1.0",
|
||||
"description": "React lifecycle adapter for the framework-independent HM OrgChart Core.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist", "README.md"],
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"build": "node build.mjs",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": { "@hm-design/org-chart": "0.1.0" },
|
||||
"peerDependencies": { "react": "^19.0.0" }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { createOrgChart } from '@hm-design/org-chart';
|
||||
import type { OrgChartDataSource, OrgChartHandle } from '@hm-design/org-chart';
|
||||
import type { OrgChartProps } from './types.js';
|
||||
|
||||
interface Instance {
|
||||
readonly handle: OrgChartHandle;
|
||||
readonly generation: number;
|
||||
source: OrgChartDataSource;
|
||||
operation: number;
|
||||
}
|
||||
|
||||
/** A React host for the exact same Core used by Vanilla HTML. */
|
||||
export function OrgChart({
|
||||
dataSource,
|
||||
aggregationPolicy,
|
||||
stylesheetUrls,
|
||||
displayMode,
|
||||
organizationLinks,
|
||||
onEmployeeClick,
|
||||
className,
|
||||
style,
|
||||
onStateChange,
|
||||
onLifecycleError,
|
||||
}: OrgChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const instanceRef = useRef<Instance | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick });
|
||||
|
||||
// Publish committed values before Core effects run, without letting callback
|
||||
// identity changes or abandoned concurrent renders alter Core ownership.
|
||||
useLayoutEffect(() => {
|
||||
committedRef.current = { dataSource, onStateChange, onLifecycleError, onEmployeeClick };
|
||||
}, [dataSource, onStateChange, onLifecycleError, onEmployeeClick]);
|
||||
|
||||
const engineStylesheetUrl = stylesheetUrls.engine;
|
||||
const coreStylesheetUrl = stylesheetUrls.core;
|
||||
const hasEmployeeCallback = typeof onEmployeeClick === 'function';
|
||||
|
||||
// Ownership changes are synchronous with the commit: an old request cannot
|
||||
// notify between a committed unmount/source change and a passive cleanup.
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const generation = ++generationRef.current;
|
||||
let handle: OrgChartHandle | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const reportError = (error: unknown) => {
|
||||
if (generationRef.current !== generation) return;
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
catch { /* Consumer errors must not leave rejected lifecycle promises. */ }
|
||||
};
|
||||
|
||||
try {
|
||||
const source = committedRef.current.dataSource;
|
||||
handle = createOrgChart({
|
||||
dataSource: source,
|
||||
aggregationPolicy,
|
||||
stylesheetUrls: { engine: engineStylesheetUrl, core: coreStylesheetUrl },
|
||||
displayMode,
|
||||
organizationLinks,
|
||||
onEmployeeClick: hasEmployeeCallback ? (employee, context) => {
|
||||
if (generationRef.current === generation) committedRef.current.onEmployeeClick?.(employee, context);
|
||||
} : undefined,
|
||||
});
|
||||
const instance: Instance = { handle, source, generation, operation: 1 };
|
||||
instanceRef.current = instance;
|
||||
unsubscribe = handle.subscribe(state => {
|
||||
if (instanceRef.current === instance && generationRef.current === generation) {
|
||||
committedRef.current.onStateChange?.(state);
|
||||
}
|
||||
});
|
||||
// Core mount performs the first load. The source effect below compares
|
||||
// identity against this record and does not load the initial source again.
|
||||
const operation = instance.operation;
|
||||
void handle.mount(container).catch(error => {
|
||||
if (instanceRef.current === instance && instance.operation === operation) reportError(error);
|
||||
});
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Invalidate callbacks and promise continuations before terminal disposal.
|
||||
if (generationRef.current === generation) ++generationRef.current;
|
||||
if (instanceRef.current?.generation === generation) instanceRef.current = null;
|
||||
try { unsubscribe?.(); }
|
||||
catch (error) {
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
catch { /* A consumer callback cannot prevent terminal Core disposal. */ }
|
||||
}
|
||||
finally {
|
||||
try { handle?.destroy(); }
|
||||
catch (error) {
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
catch { /* Cleanup still completes when the consumer callback throws. */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, organizationLinks, hasEmployeeCallback]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const instance = instanceRef.current;
|
||||
if (!instance || instance.source === dataSource) return;
|
||||
instance.source = dataSource;
|
||||
const operation = ++instance.operation;
|
||||
// Core reload synchronously starts a new revision, aborting/ignoring earlier
|
||||
// source requests even when their Promise or engine import is still pending.
|
||||
void instance.handle.reload(dataSource).catch(error => {
|
||||
if (instanceRef.current !== instance || generationRef.current !== instance.generation || instance.operation !== operation) return;
|
||||
try { committedRef.current.onLifecycleError?.(error); }
|
||||
catch { /* Consumer errors must not become unhandled Promise rejections. */ }
|
||||
});
|
||||
}, [dataSource]);
|
||||
|
||||
return <div ref={containerRef} className={className} style={style} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"use client";
|
||||
|
||||
export { OrgChart } from './OrgChart.js';
|
||||
export type { OrgChartProps } from './types.js';
|
||||
export type * from '@hm-design/org-chart';
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { OrgChartOptions, OrgChartState } from '@hm-design/org-chart';
|
||||
|
||||
/** The Core owns data, rendering, organization links and employee information
|
||||
* interactions; React owns its dedicated container. */
|
||||
export interface OrgChartProps extends Omit<OrgChartOptions, 'container' | 'onStateChange'> {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** Latest committed callback. Controlled data/style failures arrive as error states. */
|
||||
onStateChange?: (state: OrgChartState) => void;
|
||||
/** Unexpected create/mount/reload/cleanup errors, rather than controlled error states. */
|
||||
onLifecycleError?: (error: unknown) => void;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": false,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noEmit": true,
|
||||
"types": ["react"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
# @hm-design/org-chart v0.1
|
||||
|
||||
Framework-independent TypeScript/DOM Core backed by **dabeng orgchart 6.0.0**. No React runtime, wrapper, Storybook, navigation or API client. This version renders organizational hierarchy and employee/leader cards; additional relations are validated but are not drawn.
|
||||
|
||||
P1 composes a separate top leader, presentation-only hub, hierarchy, position/affiliation summaries and dispatched/onLeave groups. The same `presentation.groups` fields hide each group independently. The hub does not create a fictitious organization or change `parentId`.
|
||||
|
||||
```js
|
||||
import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart';
|
||||
|
||||
const chart = createOrgChart({
|
||||
container: document.getElementById('chart'),
|
||||
dataSource: new JsonOrgChartDataSource(configuredJsonUrl),
|
||||
// Host/bundler resolves the two exported CSS assets to absolute URLs.
|
||||
stylesheetUrls: { engine: engineCssUrl, core: coreCssUrl },
|
||||
onStateChange(state) { /* idle/loading/ready/error/destroyed */ },
|
||||
});
|
||||
await chart.mount();
|
||||
await chart.update(nextDataset);
|
||||
await chart.reload();
|
||||
chart.destroy();
|
||||
```
|
||||
|
||||
`displayMode` (optional, default `'interactive'`) selects the full composition presentation. `'readOnly'` is the view-only Workdesk layout: the hierarchy is fully expanded with no collapse buttons or diagnostic text (kind, ID, raw counts, vacancy, description), summaries use compact presenters of the same Summary (organization-by-position table, untitled affiliation bar and status groups), and a 1920 × 914.5 design canvas scales proportionally to the available container width and height. Standalone components ignore it.
|
||||
|
||||
## Organization links and employee information (P3)
|
||||
|
||||
### P3.2 follow-up: representative badges
|
||||
|
||||
Representative cards never render job-title, role or status badges, empty attribute wrappers or a badge row, including standalone specimens. They display `대표이사` / `홍 길 동`, retaining the approved 146×74 asset and existing typography, with no gap between position and name. The fixture's raw representative position (`경영진`), employee data and dialog fields remain unchanged. Other 1Depth/2Depth badge policies are preserved. This display policy takes precedence over the historical P3.2/P3.1 notes below.
|
||||
|
||||
### P3.2 compact typography and card logos
|
||||
|
||||
Large department cards use 24px names / 18px positions; compact cards use 18px names / 15px positions. Representative typography and the existing weights, line heights and colors are retained. Read-only dispatched/onLeave cards share the compact content layout (108×52 card, 24×44 photo slot), with their original gray surface and text colors. Status headers share the standard 2Depth 20px/700/19.7px typography; existing department-specific exceptions remain. Position and badge whitespace is removed only for display.
|
||||
|
||||
Employee-card logo contain boxes scale exactly 1.5×: small 12→18px; representative/large/status 14→21px. Original assets and footer logos stay unchanged. See [P3.2 report](../../docs/org-chart/implementation-report-p3.2.md) for before/after browser measurements and preservation evidence. Historical P3.1 geometry below describes that earlier stage.
|
||||
|
||||
### P3.1 display policy and local font loading
|
||||
|
||||
The OrgChart-scoped default family is **Noto Sans KR**; existing `--hm-orgchart-font-family` overrides remain available. The host must register its font-face once: HM Storybook already loads the existing HM Theme face. The HTML P1 host loads the unchanged `public/fonts/NotoSansKR-variable.ttf` copied to `assets/fonts/`; the React P1 host references that same source through its bundler. Global HM Theme files are unchanged. The Korean Core shell declares `lang="ko"` for consistent glyph shaping across host languages.
|
||||
|
||||
Read-only Full Composition suppresses only the `경영진` **job-title badge**. Positions (including a literal `경영진` position), other raw badges, employee records, popup fields and statistics are retained. Position whitespace is removed only in the card display. Other renderer modes keep their badge policy. All card types share one rectangular neutral SVG for a missing or failed profile image; an available profile image takes precedence. Approved cards, photo-slot dimensions and P3 status-card geometry are unchanged. See [P3.1 report](../../docs/org-chart/implementation-report-p3.1.md) for focused browser evidence and limits.
|
||||
|
||||
Optional `organizationLinks: Readonly<Record<string, string | undefined>>` maps existing organization IDs to host-configured URLs. Only original hierarchy depth 1 titles become native links. Missing, undefined or blank URLs keep ordinary titles; no destination is generated. HTTP(S) and relative URLs are accepted; invalid URLs, other schemes and credentials reject construction with `TypeError`. Group links retain native keyboard navigation and a visible focus outline, separately from employee activation.
|
||||
|
||||
All employee cards, including the representative and dispatched/onLeave cards, support click, Enter and Space in both modes. By default they open a read-only native information dialog in the unscaled shell, outside the Stage. It shows only existing name, position, organization, job title, affiliation and work-status values; unavailable optional values are omitted. The readOnly fictitious display name matches the activated card (`홍 길 동`); underlying employee data stays unchanged. Close button, ESC, modal focus trapping and card focus restoration are provided. A name-overflow tooltip closes when information UI opens.
|
||||
|
||||
To use a host-owned popup, provide the additive callback below. Supplying it replaces the built-in dialog; the host then owns popup accessibility and cleanup. The callback receives the unchanged Employee and resolved existing organization/affiliation, the focusable trigger card and original event.
|
||||
|
||||
```js
|
||||
const chart = createOrgChart({
|
||||
...configuredBaseOptions,
|
||||
organizationLinks: configuredOrganizationLinks,
|
||||
onEmployeeClick(employee, { organization, affiliation, trigger, originalEvent }) {
|
||||
openEmployeeInformation({ employee, organization, affiliation, trigger, originalEvent });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Omit `onEmployeeClick` to use the default popup. Vanilla options are captured at construction; use a new handle to change the URL map or callback. There is no public Core `setOptions` method.
|
||||
|
||||
`mount(container)` also accepts the container at mount time. Constructing a handle and importing the ESM facade require no DOM. Mount requires a connected HTMLElement. One handle owns one container, preserving host siblings. Duplicate mount and update/reload before mount or after destroy reject with a usage error. Data/Source failures become structured `error` states and resolve the operation. Destroy is synchronous, idempotent and terminal; use a new handle to mount again. `subscribe(listener)` returns an unsubscribe function; `getState()` returns the immutable current state. Consumer callback errors do not break the Core.
|
||||
|
||||
`update` captures its input immediately and preserves the current Source. `reload(source?)` optionally replaces it. The last started operation wins; earlier requests are aborted, and stale responses cannot commit. Call completion means the operation was applied, superseded, or produced a controlled error, rather than that every image/font finished loading. Regeneration resets expand/scroll state; removed keyboard focus moves to the scroll region.
|
||||
|
||||
## Data contract
|
||||
|
||||
Public `OrgChartDataset`, `Organization`, `Employee`, `Affiliation`, `Metadata`, `PresentationConfig` and Source types are exported with declarations. Preserve documented names: `displayOrder`, `jobTitle`, `profileImage`, `workStatus`. The supported schema version remains **`0.1-draft`**; no silent migration is performed. The latest implementation request requires top-level `schemaVersion`; `metadata.schemaVersion` is an optional compatibility mirror and must match when supplied. `metadata.updatedAt/updateNote` and dataset `relations/presentation` are optional extensions. The original seven design documents are historical drafts and are preserved.
|
||||
|
||||
Organization and employee IDs have independent namespaces. All references are checked; duplicate IDs within a collection, unknown fields, missing values, type errors, invalid dates/versions/statuses, parent cycles, unsafe markup/URLs and empty organizations produce `{valid:false, issues:[{code,path,message}]}` from `validateOrgChartDataset`. Zero employees is valid. Leaders outside their assigned organization require a future approved rule and are rejected with `LEADER_APPROVAL_REQUIRED`. Multiple roots remain separate trees, without a fabricated parent. Order is presentation order, then displayOrder, then code-unit ID order.
|
||||
|
||||
`normalizeOrgChartDataset` and `calculateSummary` accept validated datasets. `calculateSummary` exposes `raw` counts for unique original employee IDs: total, direct/inclusive per organization, position, affiliation and status. These literal source counts are **not an approved business headcount**. No policy gives `aggregation.status='unavailable'`/`policyRequired`. A caller may provide an explicitly approved policy (`policyVersion`, `includedStatuses`, `activeStatuses`, `includeLeaders:true`, `membershipMode:'primaryOnly'`). Unsupported leader exclusion/multiple-membership rules or invalid policies return `unsupportedPolicy`. This release does not infer business meaning for dispatched/onLeave status.
|
||||
|
||||
`JsonOrgChartDataSource` accepts an inline dataset or an HTTP(S)/relative URL, optionally `{fetch}`. Inline input is captured at construction; every load returns a fresh immutable snapshot. External HTTP/JSON errors are sanitized and distinguished by `OrgChartSourceError.code`. Source output is unknown until Core validation. Future API sources implement `load(options?:{signal?:AbortSignal}):Promise<unknown>` and do not change Core APIs. URL paths are injected by the host, not embedded in Core. External JSON Fetch is an HTTP example; it is not supported as a file:// transport.
|
||||
|
||||
## Theme and CSS ownership
|
||||
|
||||
P1 **ships an independent OrgChart design**. It requires no HM Theme, React/UI peer or font download. Resolve the two exported CSS assets to URLs; the Core loads them only into its dedicated open Shadow DOM. `style.css` includes its own scoped defaults and contains no private CSS imports. Summary-only components require only this CSS. `/browser` is a classic IIFE exposing `window.HMOrgChart`; `dist/index.js` is bundled ESM with no bare package imports.
|
||||
|
||||
All colors, typography, spacing, borders, radius and gradients belong to `src/styles/design-tokens.css` and `src/core/style-presets.ts`. Public `--hm-orgchart-*` inputs inherit from the caller's container; internal `--hm-orgchart-resolved-*` aliases resolve defaults without overriding inherited inputs. HM Theme variables do not affect this design. Workdesk organization header gradients and white heading text are measured; neutral/card styling is a documented design adaptation, not an official Workdesk value. Existing v0.1 demos and historical validation records are preserved. P1 theme-free examples are `dist/p1.html` (HTTP/ESM) and `dist/p1-classic.html` (direct file/inline JSON). See [standalone component integration](../../docs/org-chart/standalone-components-p1.md).
|
||||
|
||||
## Engine disposal boundary
|
||||
|
||||
Upstream provides no official destroy API. The facade clears its DOM, aborts requests and generation event signals, clears callback/data/engine references and disconnects the init observer through a narrow managed subclass hook. pan/zoom/minimap/drag/export are disabled to bound resource ownership; hierarchy expansion uses native buttons and engine show/hide methods. Transition listeners are removed with their discarded DOM. Browser verification records repeated lifecycle DOM, global event listener and active observer stability. It does **not** establish complete heap/GC recovery. The hook depends on pinned 6.0.0 internals and must be re-audited before an engine upgrade.
|
||||
|
||||
## Build and examples
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
npm run typecheck:org-chart
|
||||
npm run test:org-chart
|
||||
npm run build:org-chart-example
|
||||
npm run dev:org-chart-example
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:4178/index.html` for bundled ESM + external JSON. Open `examples/org-chart/dist/classic.html` directly for classic bundle + inline fictitious JSON. Both use package build outputs, never Spike code. Example controls demonstrate reload, employee movement/addition, destroy and recreation. Build artifacts are Git-ignored; source fixtures are checked in.
|
||||
|
||||
`npm run test:org-chart-browser` expects Playwright supplied by the verification environment. If not installed locally, set `ORGCHART_PLAYWRIGHT_MODULE` to its absolute `index.mjs`/`index.js` and optionally `ORGCHART_CHROME` to a Chrome executable. Production has no browser-test dependency. Evidence is written under `test-results/org-chart-browser/`.
|
||||
|
||||
The React wrapper owns only ref/effect lifecycle, source/props updates and cleanup, including forwarding the additive P3 lookup options; HTML and React render the same Core. Standalone `createOrgChartTree`, `createPositionSummary`, `createAffiliationSummary` and `createStatusSummary` factories are exported at the package root and return the existing `OrgChartHandle`. The three public OrgChart Stories are Logo · Personnel Summary, Department Colors and Full Composition. Original verification Stories and independent component Stories remain available under the excluded `internal` tag.
|
||||
@@ -0,0 +1,40 @@
|
||||
import { build } from 'vite';
|
||||
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = fileURLToPath(new URL('./', import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const engineRoot = path.dirname(require.resolve('orgchart/package.json'));
|
||||
const engine = JSON.parse(await readFile(path.join(engineRoot, 'package.json'), 'utf8'));
|
||||
if (engine.version !== '6.0.0') throw new Error('Expected pinned orgchart 6.0.0');
|
||||
// Keep upstream UMD evaluation inside the deferred import; bundling removes bare imports.
|
||||
for (const format of ['es', 'iife']) {
|
||||
await build({
|
||||
configFile: false, root, publicDir: false,
|
||||
build: {
|
||||
target: 'es2022', outDir: 'dist', emptyOutDir: false, minify: false,
|
||||
lib: {
|
||||
entry: path.join(root, 'src/index.ts'), name: 'HMOrgChart', formats: [format],
|
||||
fileName: () => format === 'es' ? 'index.js' : 'orgchart.browser.js',
|
||||
},
|
||||
rolldownOptions: { output: { codeSplitting: false } },
|
||||
},
|
||||
});
|
||||
}
|
||||
const typescriptManifest = require.resolve('typescript/package.json');
|
||||
const typescript = JSON.parse(await readFile(typescriptManifest, 'utf8'));
|
||||
execFileSync(process.execPath, [path.resolve(path.dirname(typescriptManifest), typescript.bin.tsc), '-p', path.join(root, 'tsconfig.build.json')], { stdio: 'inherit' });
|
||||
// Ship one self-contained public CSS asset. ?url and file:// consumers cannot
|
||||
// rely on source-relative imports or a bundler to discover private style files.
|
||||
const coreStyles = await readFile(path.join(root, 'src/styles/org-chart.css'), 'utf8');
|
||||
const inlinedStyles = await Promise.all(['design-tokens.css', 'employee-card.css', 'summary-sections.css', 'org-chart-content.css', 'org-chart-readonly.css', 'org-chart-p3-summary.css', 'employee-info-dialog.css'].map(async file => [file, await readFile(path.join(root, 'src/styles', file), 'utf8')]));
|
||||
let publicStyles = coreStyles;
|
||||
for (const [file, css] of inlinedStyles) publicStyles = publicStyles.replace(`@import './${file}';`, css);
|
||||
await writeFile(path.join(root, 'dist/style.css'), publicStyles);
|
||||
await cp(path.join(engineRoot, 'dist/css/orgchart.css'), path.join(root, 'dist/engine.css'));
|
||||
await mkdir(path.join(root, 'dist/licenses'), { recursive: true });
|
||||
await cp(path.join(engineRoot, 'LICENSE'), path.join(root, 'dist/licenses/orgchart-MIT.txt'));
|
||||
await writeFile(path.join(root, 'dist/licenses/README.txt'), 'Bundled engine: dabeng orgchart 6.0.0 (MIT). Upstream UMD references html2canvas/jspdf only for export, which this Core disables. Those export libraries are not bundled or supported. json-digger is not imported by the shipped UMD. The npm dependency retains the upstream dependency manifest.\n');
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@hm-design/org-chart",
|
||||
"version": "0.1.0",
|
||||
"description": "Framework-independent HM OrgChart core, data contract and JSON sources.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" },
|
||||
"./style.css": "./dist/style.css",
|
||||
"./engine.css": "./dist/engine.css",
|
||||
"./browser": "./dist/orgchart.browser.js"
|
||||
},
|
||||
"files": ["dist", "README.md"],
|
||||
"sideEffects": ["**/*.css"],
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"build": "node build.mjs",
|
||||
"test": "node test.mjs",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": { "orgchart": "6.0.0" }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# 관계사 로고 출처
|
||||
|
||||
사용자가 공식 접근 가능한 Figma Asset 재사용을 명시적으로 요청하여, 2026-10-12에 아래 Figma 파일의 관계사별 집계 Footer에서 가져온 PNG를 보존했습니다. 원본 로고를 그대로 사용하며 OrgChart 공통 카드·Footer는 같은 로컬 Asset을 참조합니다.
|
||||
|
||||
- Figma file key: `Plr6RRLi5XnLIn60x9aG1s`
|
||||
- Footer 원본: [관계사별 집계 Footer, node 22:179](https://www.figma.com/design/Plr6RRLi5XnLIn60x9aG1s/?node-id=22-179)
|
||||
- 확인일: `2026-10-12` (KST)
|
||||
- 사용 범위: OrgChart 내부 관계사 표시. 실제 인물 프로필 사진은 다운로드하지 않았습니다.
|
||||
- 배포 경로: 로컬 PNG를 `?inline` import하여 번들에 포함합니다. Figma 임시 원격 다운로드 URL은 배포하지 않습니다.
|
||||
- 로고 필드·Employee JSON 데이터 계약은 추가하지 않습니다. 기존 관계사 ID와 내부 Asset 매핑을 사용하며, 매핑이 없는 ID나 이미지 오류는 관계사명 모노그램으로 대체합니다.
|
||||
- 접근 가능한 원본 Footer의 시각적 확인 결과를 근거로 합니다. 통합 카드·Footer의 최종 브라우저 검증은 별도 통합 검증 기록을 따릅니다.
|
||||
|
||||
원본 Node 및 저장 파일 SHA-256 (모두 0바이트 초과 확인):
|
||||
|
||||
- `hanmac.png` — node `22:181`; 11,828 bytes; `db154af30d5ef875d097006c92dc5bdd70962ed2538cfdd385c87b39cecb3cfa`
|
||||
- `saman.png` — node `22:187`; 20,584 bytes; `5167420984ef7b0603f8203f35a99ffcfcbc7981186beb785ce38a1665fda60c`
|
||||
- `jangheon.png` — node `22:193`; 56,063 bytes; `6b4047ae6ecc451121470885d1f382b3f499c838968a02120d6d3dd643503e63`
|
||||
- `jangheon-industry.png` — node `22:199`; 16,683 bytes; `6d12832bfe067414889d6db2ec4ee4910f5d2e61dbd86b9edd1516ca2a51733d`
|
||||
- `ptc.png` — node `22:205`; 7,613 bytes; `12c22132e83be1f4feb23e5f761b73a85366ae399387cd6b40782d501dd7cadb`
|
||||
- `halla.png` — node `22:211`; 9,606 bytes; `f92408ae7f129e3427c1456de0d9da855bbd652e72b97728e3cdc6b11fa6d12b`
|
||||
- `baron.png` — node `22:217`; 13,175 bytes; `089014f781d6b741397388eb87c70b9817e848eb5960fc5b7933033b724ca351`
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,13 @@
|
||||
# OrgChart 1단계 카드 자산
|
||||
|
||||
- `대표자.svg`: 사용자가 본문으로 전달한 SVG. 최상단 독립 `topLeader` 전용, 146×74. 부서 자산 매핑에 넣지 않는다.
|
||||
- `사업관리팀.svg`, `사업관리팀-sm.svg`: 사용자 PNG에서 색상·알파·내측 그라데이션 방향을 추출한 벡터. 조직장 146×68 / 일반 직원 108×52. PNG를 SVG에 삽입하지 않는다.
|
||||
- 경영기획팀 / 디자인기획팀 / 솔루션통합팀 / 인재성장팀 / 통합시스템 디비전의 큰·작은 SVG 10개: `D:\디자인시스템\카드 이미지`에서 원본 바이트를 그대로 복사했다. SHA-256 일치는 `test-results/org-chart-card-design/browser.json`에서 확인한다.
|
||||
|
||||
부서 매핑은 `src/core/department-card-assets.ts`, 대표자 전용 매핑은 `src/core/representative-card-asset.ts`에 있다. `solution-sales`에 솔루션통합팀 자산을 쓰는 것은 이번 작업에서 사용자가 명시 승인했다. 하위 조직은 실제 `parentId`로 상위 부서 자산을 찾으며 데이터 JSON을 수정하지 않는다.
|
||||
|
||||
사업관리 PNG 원본은 확장자가 없는 `D:\디자인시스템\카드 이미지\사업관리`(PNG, 408×191)다. 추출 색은 외곽 `#DEE6F0 → #A8BBD1 → #577BA5`(마지막 opacity 0.6), 내측 `#FFFFFF → #E0E6F4`다. 경영기획과 색상 끝점은 같지만 사업관리 PNG의 투명도와 내측 그라데이션 방향을 별도로 유지했다.
|
||||
|
||||
재생성: `examples/org-chart/create-business-card-assets.py <원본 PNG 경로>`. Pillow/numpy가 필요하다. 추출 근거는 `business-management-colors.json`, Chrome 실제 래스터 비교는 `business-management-comparison.json`에 보존한다. 신규 벡터의 외곽·곡선 path는 같은 크기의 경영기획 SVG를 재사용한다.
|
||||
|
||||
SVG는 `?inline`으로 번들에 포함되고 별도 장식 이미지 레이어로 렌더링된다. 직원 이름·직위·직책·상태·프로필·관계사 로고는 기존 데이터 레이어다. 파견/휴직 보조 영역에는 새 자산을 전달하지 않는다. 고정 카드 크기의 소비자 override는 이 원본 비율 variant에 적용하지 않는다.
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg width="108" height="52" viewBox="0 0 108 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 48.494C0 50.4303 1.5697 52 3.50602 52H104.494C106.43 52 108 50.4303 108 48.494V3.50602C108 1.5697 106.43 0 104.494 0H3.50602C1.5697 0 0 1.5697 0 3.50602V48.494Z" fill="url(#paint0_linear_69_2371)"/>
|
||||
<path d="M1.47852 48.1332C1.47852 49.4241 2.52498 50.4706 3.81587 50.4706H104.334C105.541 50.4706 106.52 49.492 106.52 48.2849C106.52 22.4626 85.5865 1.5294 59.7641 1.5294H3.81587C2.52499 1.5294 1.47852 2.57587 1.47852 3.86675V48.1332Z" fill="url(#paint1_linear_69_2371)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_69_2371" x1="92.1155" y1="10.1048" x2="66.3798" y2="53.0473" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DEE6F0"/>
|
||||
<stop offset="0.72" stop-color="#A8BBD1"/>
|
||||
<stop offset="1" stop-color="#577BA5"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_69_2371" x1="57.7525" y1="19.8874" x2="43.9106" y2="50.2297" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.97" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="146" height="68" viewBox="0 0 146 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 64.494C0 66.4303 1.5697 68 3.50602 68H142.494C144.43 68 146 66.4303 146 64.494V3.50603C146 1.5697 144.43 0 142.494 0H3.50602C1.5697 0 0 1.5697 0 3.50603V64.494Z" fill="url(#paint0_linear_67_1090)"/>
|
||||
<path d="M2 63.6627C2 64.9535 3.04647 66 4.33735 66H141.663C142.954 66 144 64.9535 144 63.6627V52C144 24.3858 121.614 2 94 2H4.33735C3.04647 2 2 3.04647 2 4.33735V63.6627Z" fill="url(#paint1_linear_67_1090)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_67_1090" x1="124.527" y1="13.2139" x2="91.4091" y2="70.3397" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DEE6F0"/>
|
||||
<stop offset="0.72" stop-color="#A8BBD1"/>
|
||||
<stop offset="1" stop-color="#577BA5"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_67_1090" x1="78.0741" y1="26.0067" x2="60.3685" y2="66.1292" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.97" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1013 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="146" height="74" viewBox="0 0 146 74" fill="none">
|
||||
<path d="M0 70C0 72.2091 1.79086 74 4 74H142C144.209 74 146 72.2091 146 70V4.00001C146 1.79086 144.209 0 142 0H4C1.79086 0 0 1.79086 0 4V70Z" fill="url(#paint0_linear_22_271)"/>
|
||||
<path d="M2 69.6627C2 70.9535 3.04647 72 4.33735 72H142.013C143.11 72 144 71.1103 144 70.0129C144 32.4504 113.55 2 75.9871 2H4.33735C3.04647 2 2 3.04647 2 4.33735V69.6627Z" fill="url(#paint1_linear_22_271)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_22_271" x1="123.344" y1="9.12442" x2="104.553" y2="74.0869" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A7B9DF"/>
|
||||
<stop offset="0.6" stop-color="#A7B9DF"/>
|
||||
<stop offset="1" stop-color="#0135A1" stop-opacity="0.5"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_22_271" x1="77.3365" y1="18.8479" x2="77.3365" y2="72" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="108" height="52" viewBox="0 0 108 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 48.494C0 50.4303 1.5697 52 3.50602 52H104.494C106.43 52 108 50.4303 108 48.494V3.50602C108 1.5697 106.43 3.73075e-09 104.494 3.73075e-09H3.50602C1.5697 3.73075e-09 0 1.5697 0 3.50602V48.494Z" fill="url(#paint0_linear_69_2374)"/>
|
||||
<path d="M1.48047 48.1332C1.48047 49.4241 2.52694 50.4706 3.81782 50.4706H104.336C105.543 50.4706 106.522 49.492 106.522 48.2849C106.522 22.4626 85.5884 1.5294 59.7661 1.5294H3.81782C2.52694 1.5294 1.48047 2.57587 1.48047 3.86675V48.1332Z" fill="url(#paint1_linear_69_2374)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_69_2374" x1="92.1156" y1="10.1048" x2="66.3799" y2="53.0473" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DDEEEF"/>
|
||||
<stop offset="0.72" stop-color="#A2D1D1"/>
|
||||
<stop offset="1" stop-color="#319D99" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_69_2374" x1="58.7484" y1="16.1449" x2="54.0638" y2="50.215" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="146" height="68" viewBox="0 0 146 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 64.494C0 66.4303 1.5697 68 3.50602 68H142.494C144.43 68 146 66.4303 146 64.494V3.50605C146 1.56973 144.43 3.05176e-05 142.494 3.05176e-05H3.50603C1.5697 3.05176e-05 0 1.56973 0 3.50605V64.494Z" fill="url(#paint0_linear_67_59)"/>
|
||||
<path d="M2 63.6627C2 64.9535 3.04647 66 4.33735 66H141.663C142.954 66 144 64.9535 144 63.6627V52C144 24.3858 121.614 2 94 2H4.33735C3.04647 2 2 3.04647 2 4.33735V63.6627Z" fill="url(#paint1_linear_67_59)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_67_59" x1="124.527" y1="13.214" x2="91.4092" y2="70.3397" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DDEEEF"/>
|
||||
<stop offset="0.72" stop-color="#A2D1D1"/>
|
||||
<stop offset="1" stop-color="#319D99" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_67_59" x1="79.4178" y1="21.1125" x2="73.4847" y2="65.7189" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="108" height="52" viewBox="0 0 108 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 48.494C0 50.4303 1.5697 52 3.50602 52H104.494C106.43 52 108 50.4303 108 48.494V3.50602C108 1.5697 106.43 0 104.494 0H3.50602C1.5697 0 0 1.5697 0 3.50602V48.494Z" fill="url(#business_management_outer_69_2371)"/>
|
||||
<path d="M1.47852 48.1332C1.47852 49.4241 2.52498 50.4706 3.81587 50.4706H104.334C105.541 50.4706 106.52 49.492 106.52 48.2849C106.52 22.4626 85.5865 1.5294 59.7641 1.5294H3.81587C2.52499 1.5294 1.47852 2.57587 1.47852 3.86675V48.1332Z" fill="url(#business_management_inner_69_2371)"/>
|
||||
<defs>
|
||||
<linearGradient id="business_management_outer_69_2371" x1="92.1155" y1="10.1048" x2="66.3798" y2="53.0473" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DEE6F0"/>
|
||||
<stop offset="0.72" stop-color="#A8BBD1"/>
|
||||
<stop offset="1" stop-color="#577BA5" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="business_management_inner_69_2371" x1="57.7534" y1="15.6726" x2="53.1643" y2="49.8431" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="146" height="68" viewBox="0 0 146 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 64.494C0 66.4303 1.5697 68 3.50602 68H142.494C144.43 68 146 66.4303 146 64.494V3.50603C146 1.5697 144.43 0 142.494 0H3.50602C1.5697 0 0 1.5697 0 3.50603V64.494Z" fill="url(#business_management_outer_67_1090)"/>
|
||||
<path d="M2 63.6627C2 64.9535 3.04647 66 4.33735 66H141.663C142.954 66 144 64.9535 144 63.6627V52C144 24.3858 121.614 2 94 2H4.33735C3.04647 2 2 3.04647 2 4.33735V63.6627Z" fill="url(#business_management_inner_67_1090)"/>
|
||||
<defs>
|
||||
<linearGradient id="business_management_outer_67_1090" x1="124.527" y1="13.2139" x2="91.4091" y2="70.3397" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DEE6F0"/>
|
||||
<stop offset="0.72" stop-color="#A8BBD1"/>
|
||||
<stop offset="1" stop-color="#577BA5" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="business_management_inner_67_1090" x1="78.0741" y1="20.4949" x2="71.8702" y2="65.1795" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="108" height="52" viewBox="0 0 108 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 48.494C0 50.4303 1.5697 52 3.50602 52H104.494C106.43 52 108 50.4303 108 48.494V3.50602C108 1.5697 106.43 3.73075e-09 104.494 3.73075e-09H3.50602C1.5697 3.73075e-09 0 1.5697 0 3.50602V48.494Z" fill="url(#paint0_linear_69_2375)"/>
|
||||
<path d="M1.48047 48.1332C1.48047 49.4241 2.52694 50.4706 3.81782 50.4706H104.336C105.543 50.4706 106.522 49.492 106.522 48.2849C106.522 22.4626 85.5884 1.5294 59.7661 1.5294H3.81782C2.52694 1.5294 1.48047 2.57587 1.48047 3.86675V48.1332Z" fill="url(#paint1_linear_69_2375)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_69_2375" x1="92.1156" y1="10.1048" x2="66.3799" y2="53.0473" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E0EAE1"/>
|
||||
<stop offset="0.72" stop-color="#B8CDB3"/>
|
||||
<stop offset="1" stop-color="#B8CDB3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_69_2375" x1="58.8193" y1="16.2688" x2="54.1615" y2="50.2168" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="146" height="68" viewBox="0 0 146 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 64.494C0 66.4303 1.5697 68 3.50602 68H142.494C144.43 68 146 66.4303 146 64.494V3.50602C146 1.5697 144.43 0 142.494 0H3.50603C1.5697 0 0 1.56969 0 3.50602V64.494Z" fill="url(#paint0_linear_67_67)"/>
|
||||
<path d="M2 63.6627C2 64.9535 3.04647 66 4.33735 66H141.663C142.954 66 144 64.9535 144 63.6627V52C144 24.3858 121.614 2.00002 94 2.00002H4.33735C3.04647 2.00002 2 3.04648 2 4.33736V63.6627Z" fill="url(#paint1_linear_67_67)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_67_67" x1="124.527" y1="13.2139" x2="91.4092" y2="70.3397" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E0EAE1"/>
|
||||
<stop offset="0.72" stop-color="#B8CDB3"/>
|
||||
<stop offset="1" stop-color="#B8CDB3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_67_67" x1="79.5136" y1="21.2746" x2="73.6145" y2="65.721" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1020 B |
@@ -0,0 +1,15 @@
|
||||
<svg width="108" height="52" viewBox="0 0 108 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 48.494C0 50.4303 1.5697 52 3.50602 52H104.494C106.43 52 108 50.4303 108 48.494V3.50602C108 1.5697 106.43 3.73075e-09 104.494 3.73075e-09H3.50602C1.5697 3.73075e-09 0 1.5697 0 3.50602V48.494Z" fill="url(#paint0_linear_69_2372)"/>
|
||||
<path d="M1.47726 48.1939C1.47795 49.4848 2.52498 50.5306 3.81586 50.5299L104.36 50.4718C105.567 50.4711 106.545 49.492 106.545 48.2849V48.2849C106.531 22.4625 85.5865 1.54149 59.7641 1.5564L3.78958 1.58873C2.4987 1.58948 1.45279 2.63655 1.45349 3.92743L1.47726 48.1939Z" fill="url(#paint1_linear_69_2372)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_69_2372" x1="92.1156" y1="10.1048" x2="66.3799" y2="53.0473" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B8CFEA"/>
|
||||
<stop offset="0.72" stop-color="#BFD6F0"/>
|
||||
<stop offset="1" stop-color="#3D86D4" stop-opacity="0.7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_69_2372" x1="58.5614" y1="15.8664" x2="53.7835" y2="50.4693" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="146" height="68" viewBox="0 0 146 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 64.494C0 66.4303 1.5697 68 3.50602 68H142.494C144.43 68 146 66.4303 146 64.494V3.50602C146 1.5697 144.43 0 142.494 0H3.50603C1.5697 0 0 1.56969 0 3.50602V64.494Z" fill="url(#paint0_linear_67_1091)"/>
|
||||
<path d="M1.9987 63.742C1.99942 65.0329 3.04647 66.0788 4.33735 66.078L141.698 66.0013C142.989 66.0006 144.035 64.9535 144.034 63.6626L144.028 52C144.012 24.3858 121.614 2.0125 94 2.02793L4.30182 2.07805C3.01094 2.07877 1.96505 3.12582 1.96576 4.4167L1.9987 63.742Z" fill="url(#paint1_linear_67_1091)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_67_1091" x1="124.527" y1="13.2139" x2="91.4092" y2="70.3397" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#B8CFEA"/>
|
||||
<stop offset="0.72" stop-color="#BFD6F0"/>
|
||||
<stop offset="1" stop-color="#3D86D4" stop-opacity="0.7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_67_1091" x1="79.1676" y1="20.7484" x2="73.1165" y2="66.0528" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="108" height="52" viewBox="0 0 108 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 48.494C0 50.4303 1.5697 52 3.50602 52H104.494C106.43 52 108 50.4303 108 48.494V3.50602C108 1.5697 106.43 0 104.494 0H3.50602C1.5697 0 0 1.5697 0 3.50602V48.494Z" fill="url(#paint0_linear_69_2373)"/>
|
||||
<path d="M1.47852 48.1332C1.47852 49.4241 2.52498 50.4706 3.81587 50.4706H104.334C105.541 50.4706 106.52 49.492 106.52 48.2849C106.52 22.4626 85.5865 1.52939 59.7641 1.52939H3.81587C2.52499 1.52939 1.47852 2.57586 1.47852 3.86674V48.1332Z" fill="url(#paint1_linear_69_2373)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_69_2373" x1="92.1156" y1="10.1048" x2="66.3799" y2="53.0473" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DFDEE9"/>
|
||||
<stop offset="0.72" stop-color="#AFA4C3"/>
|
||||
<stop offset="1" stop-color="#7F6B9D" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_69_2373" x1="58.4371" y1="16.1449" x2="53.7525" y2="50.215" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="146" height="68" viewBox="0 0 146 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 64.494C0 66.4303 1.5697 68 3.50602 68H142.494C144.43 68 146 66.4303 146 64.494V3.50603C146 1.5697 144.43 0 142.494 0H3.50603C1.5697 0 0 1.5697 0 3.50603V64.494Z" fill="url(#paint0_linear_67_51)"/>
|
||||
<path d="M2 63.6627C2 64.9536 3.04647 66 4.33735 66H141.663C142.954 66 144 64.9536 144 63.6627V52C144 24.3858 121.614 2.00002 94 2.00002H4.33735C3.04647 2.00002 2 3.04648 2 4.33736V63.6627Z" fill="url(#paint1_linear_67_51)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_67_51" x1="124.527" y1="13.2139" x2="91.4092" y2="70.3397" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#DFDEE9"/>
|
||||
<stop offset="0.72" stop-color="#AFA4C3"/>
|
||||
<stop offset="1" stop-color="#7F6B9D" stop-opacity="0.6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_67_51" x1="78.9996" y1="21.1126" x2="73.0666" y2="65.719" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="#E0E6F4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" fill="#f1f5f8"/>
|
||||
<circle cx="32" cy="23" r="10" fill="#8c9ead"/>
|
||||
<path d="M14 54v-3c0-10 8-16 18-16s18 6 18 16v3H14Z" fill="#8c9ead"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,32 @@
|
||||
<svg width="1758" height="901" viewBox="0 0 1758 901" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1334.26 434.607C1325.83 686.168 1115.1 883.287 863.544 874.862C611.983 866.436 414.864 655.706 423.289 404.145C431.715 152.584 642.444 -44.535 894.005 -36.2021C1145.57 -27.7766 1342.69 182.953 1334.26 434.514V434.607Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1317.01 434.028C1308.91 676.065 1106.16 865.721 864.121 857.614C622.084 849.508 432.428 646.757 440.535 404.72C448.641 162.684 651.392 -26.9719 893.429 -18.9545C1135.46 -10.848 1325.12 191.903 1317.01 433.939V434.028Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1300.56 433.477C1292.76 666.428 1097.62 848.965 864.671 841.163C631.72 833.361 449.183 638.22 456.985 405.269C464.787 172.318 659.927 -10.2191 892.879 -2.50268C1125.83 5.29951 1308.37 200.44 1300.56 433.391V433.477Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1281.35 432.833C1273.91 655.173 1087.65 829.395 865.313 821.948C642.973 814.501 468.751 628.25 476.197 405.91C483.644 183.569 669.896 9.34707 892.236 16.712C1114.58 24.1588 1288.8 210.411 1281.35 432.751V432.833Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1260.18 432.123C1253.13 642.771 1076.67 807.832 866.021 800.776C655.373 793.721 490.312 617.264 497.367 406.615C504.423 195.967 680.88 30.9065 891.528 37.8842C1102.18 44.9394 1267.24 221.397 1260.18 432.045V432.123Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1233.13 431.215C1226.57 626.922 1062.63 780.274 866.926 773.72C671.219 767.165 517.867 603.224 524.422 407.517C530.976 211.811 694.917 58.4586 890.624 64.9413C1086.33 71.4961 1239.68 235.437 1233.13 431.143V431.215Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1200.59 430.124C1194.64 607.86 1045.75 747.131 868.014 741.178C690.278 735.225 551.007 586.338 556.96 408.602C562.913 230.867 711.8 91.5959 889.535 97.4834C1067.27 103.436 1206.54 252.323 1200.59 430.059V430.124Z" stroke="#CFE3F6" stroke-width="0.663562"/>
|
||||
<path d="M1350.93 435.148C1342.22 695.968 1123.72 900.309 862.989 891.606C602.169 882.903 397.828 664.488 406.531 403.668C415.235 142.849 633.742 -61.4927 894.469 -52.7895C1155.29 -44.0862 1359.63 174.421 1350.93 435.148Z" stroke="#D6E9F8" stroke-width="0.663562"/>
|
||||
<path d="M1367.59 435.713C1358.61 705.699 1132.42 917.262 862.341 908.281C592.355 899.3 380.792 673.108 389.773 403.03C398.754 133.044 624.946 -78.6117 895.025 -69.5381C1165.01 -60.5571 1376.67 165.727 1367.59 435.713Z" stroke="#D6E9F8" stroke-opacity="0.9" stroke-width="0.663562"/>
|
||||
<path d="M1384.35 436.269C1375 715.514 1141.12 934.299 861.878 924.947C582.633 915.596 363.848 681.719 373.199 402.474C382.551 123.23 616.428 -95.5552 895.672 -86.2964C1174.92 -76.9451 1393.7 156.932 1384.35 436.176V436.269Z" stroke="#D6E9F8" stroke-opacity="0.9" stroke-width="0.663562"/>
|
||||
<path d="M1401.02 436.829C1391.39 725.332 1149.73 951.339 861.323 941.71C572.912 932.081 346.812 690.427 356.441 402.016C366.071 113.512 607.725 -112.494 896.136 -102.865C1184.55 -93.2361 1410.65 148.418 1401.02 436.829Z" stroke="#D6E9F8" stroke-opacity="0.8" stroke-width="0.663562"/>
|
||||
<path d="M1417.68 437.366C1407.78 735.036 1158.34 968.357 860.674 958.358C563.004 948.451 329.776 699.019 339.683 401.35C349.59 103.68 599.021 -129.641 896.691 -119.642C1194.36 -109.735 1427.68 139.697 1417.68 437.366Z" stroke="#D6E9F8" stroke-opacity="0.8" stroke-width="0.663562"/>
|
||||
<path d="M1434.44 437.94C1424.16 744.869 1167.14 985.319 860.211 975.042C553.283 964.765 312.832 707.741 323.11 400.813C333.387 93.8843 590.503 -146.566 897.339 -136.381C1204.27 -126.104 1444.72 131.012 1434.44 437.848V437.94Z" stroke="#D6E9F8" stroke-opacity="0.7" stroke-width="0.663562"/>
|
||||
<path d="M1451.11 438.489C1440.55 754.584 1175.75 1002.35 859.563 991.793C543.469 981.238 295.704 716.437 306.259 400.25C316.814 84.1555 581.615 -163.609 897.802 -153.054C1213.9 -142.499 1461.66 122.302 1451.11 438.489Z" stroke="#D6E9F8" stroke-opacity="0.7" stroke-width="0.663562"/>
|
||||
<path d="M1467.87 439.047C1457.03 764.4 1184.45 1019.39 859.101 1008.46C533.747 997.629 278.853 725.05 289.686 399.697C300.519 74.3436 573.097 -180.643 898.451 -169.718C1223.8 -158.885 1478.79 113.693 1467.87 439.047Z" stroke="#D6E9F8" stroke-opacity="0.6" stroke-width="0.663562"/>
|
||||
<path d="M1484.53 439.6C1473.33 774.212 1193.06 1036.33 858.544 1025.22C524.024 1014.01 261.816 733.751 272.926 399.232C284.129 64.6196 564.393 -197.589 898.912 -186.386C1233.43 -175.183 1495.64 105.08 1484.44 439.6H1484.53Z" stroke="#D6E9F8" stroke-opacity="0.6" stroke-width="0.663562"/>
|
||||
<path d="M1501.2 440.162C1489.72 783.941 1201.68 1053.37 857.898 1041.89C514.119 1030.41 244.689 742.462 256.17 398.591C267.743 54.8121 555.691 -214.526 899.562 -203.045C1243.34 -191.564 1512.77 96.4766 1501.29 440.255L1501.2 440.162Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1517.95 440.713C1506.2 793.751 1210.47 1070.4 857.433 1058.55C504.396 1046.79 227.837 751.068 239.595 398.03C251.354 45.0858 547.079 -231.566 900.116 -219.808C1253.15 -208.049 1529.81 87.6762 1517.95 440.713Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1534.62 441.355C1522.49 803.651 1219.08 1087.43 856.878 1075.4C494.674 1063.27 210.8 759.857 222.837 397.653C234.966 35.2646 538.376 -248.61 900.672 -236.481C1262.88 -224.352 1546.75 79.0587 1534.62 441.355Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1551.29 441.908C1538.88 813.37 1227.69 1104.47 856.23 1092.06C484.768 1079.65 193.672 768.465 206.079 397.003C218.578 25.4479 529.766 -265.555 901.228 -253.149C1272.69 -240.742 1563.79 70.4455 1551.38 441.908H1551.29Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1568.04 442.467C1555.36 823.188 1236.4 1121.51 855.767 1108.73C475.046 1096.04 176.728 777.079 189.413 396.451C202.097 15.7302 521.062 -282.588 901.783 -269.903C1282.41 -257.219 1580.82 61.7463 1568.04 442.375V442.467Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1584.71 443.026C1571.66 832.913 1245.1 1138.45 855.12 1125.49C465.233 1112.44 159.694 785.786 172.748 395.899C185.711 5.91949 512.453 -299.62 902.34 -286.565C1292.23 -273.51 1597.77 53.1393 1584.71 443.026Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1601.38 443.581C1588.04 842.727 1253.71 1155.49 854.564 1142.16C455.511 1128.82 142.749 794.396 155.989 395.343C169.322 -3.80306 503.749 -316.565 902.895 -303.324C1302.04 -289.992 1614.8 44.3425 1601.47 443.488L1601.38 443.581Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1619.26 444.179C1605.6 853.201 1262.99 1173.7 853.966 1160.04C445.038 1146.38 124.538 803.675 138.105 394.747C151.768 -14.2755 494.47 -334.776 903.493 -321.208C1312.52 -307.546 1633.02 35.0614 1619.35 444.084L1619.26 444.179Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1638.07 444.808C1624.06 864.223 1272.75 1192.87 853.337 1178.86C434.019 1164.85 105.375 813.438 119.287 394.12C133.297 -25.2949 484.707 -353.939 904.122 -340.027C1323.54 -326.017 1652.18 25.2956 1638.17 444.711L1638.07 444.808Z" stroke="#D6E9F8" stroke-opacity="0.5" stroke-width="0.663562"/>
|
||||
<path d="M1659.66 445.53C1645.26 876.87 1283.95 1214.86 852.615 1200.45C421.374 1186.04 83.3858 824.641 97.6938 393.401C112.102 -37.9395 473.503 -375.928 904.844 -361.62C1336.18 -347.212 1674.17 14.0895 1659.76 445.43L1659.66 445.53Z" stroke="#D6E9F8" stroke-opacity="0.4" stroke-width="0.663562"/>
|
||||
<path d="M1682.94 446.308C1668.1 890.503 1296.03 1238.56 851.836 1223.73C407.744 1208.89 59.6834 836.717 74.4177 392.626C89.2552 -51.5694 461.427 -399.63 905.622 -384.896C1349.82 -370.059 1697.88 2.0101 1683.04 446.205L1682.94 446.308Z" stroke="#D6E9F8" stroke-opacity="0.4" stroke-width="0.663562"/>
|
||||
<path d="M1708.4 447.16C1693.09 905.419 1309.24 1264.5 850.985 1249.19C392.832 1233.89 33.7502 849.93 48.9511 391.777C64.2583 -66.482 448.214 -425.563 906.473 -410.362C1364.73 -395.055 1723.81 -11.206 1708.51 447.053L1708.4 447.16Z" stroke="#D6E9F8" stroke-opacity="0.3" stroke-width="0.663562"/>
|
||||
<path d="M1731.81 447.942C1716.07 919.134 1321.39 1288.35 850.201 1272.61C379.119 1256.87 9.90427 862.08 25.5341 390.997C41.2733 -80.1944 436.064 -449.409 907.256 -433.779C1378.45 -418.04 1747.66 -23.3586 1731.92 447.833L1731.81 447.942Z" stroke="#D6E9F8" stroke-opacity="0.3" stroke-width="0.663562"/>
|
||||
<path d="M1756.52 448.769C1740.33 933.607 1334.21 1313.51 849.375 1297.32C364.65 1281.12 -15.2584 874.9 0.824129 390.174C17.0191 -94.664 423.244 -474.572 908.082 -458.489C1392.92 -442.294 1772.83 -36.182 1756.63 448.656L1756.52 448.769Z" stroke="#D6E9F8" stroke-opacity="0.3" stroke-width="0.663562"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.5 KiB |