From d42466f5eb3486db619e0fa170433f23ebe0f680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=88=98=ED=98=84?= Date: Wed, 14 Oct 2026 13:33:59 +0900 Subject: [PATCH] fix: align org chart hierarchy and typography weights --- AGENTS.md | 6 + CLAUDE.md | 3 + docs/components/card.md | 14 +- docs/components/dialog.md | 2 +- docs/guidelines/ai-screen-contract.md | 2 + docs/guidelines/테마-토큰-가이드.md | 13 ++ docs/org-chart/ai-page-generation-rules.md | 11 ++ docs/org-chart/design-approval.md | 3 + docs/org-chart/expanded-usage.md | 2 + docs/org-chart/org-chart-usage-guide.md | 6 +- .../verify-hierarchy-stories.mjs | 146 ++++++++++++++++++ .../org-chart/create-expanded-fixtures.mjs | 8 +- examples/org-chart/data-expanded-large.json | 70 ++++----- examples/org-chart/verify-card-content.mjs | 2 +- .../org-chart/verify-final-card-check.mjs | 2 +- examples/org-chart/verify-p3.1.mjs | 4 +- examples/org-chart/verify-p3.mjs | 6 +- .../org-chart-react/src/HMEmployeeDialog.tsx | 2 +- packages/org-chart/src/data/examples.test.ts | 25 ++- .../org-chart/src/styles/employee-card.css | 6 +- .../src/styles/org-chart-readonly.css | 4 +- packages/ui/src/components/ui/card.tsx | 2 +- packages/ui/src/theme/card.css | 4 +- scripts/font-weight-policy.test.mjs | 60 +++++++ src/center/center-stories.css | 2 +- src/components/button.stories.tsx | 2 +- src/components/carousel.stories.tsx | 2 +- src/components/select.stories.tsx | 6 +- src/foundation/typography.stories.tsx | 8 +- src/hm-theme.css | 12 +- src/theme/designer-theme.css | 2 +- src/theme/verification-baseline.json | 2 +- src/work-dashboard/work-dashboard.css | 4 +- 33 files changed, 358 insertions(+), 85 deletions(-) create mode 100644 examples/org-chart-react/verify-hierarchy-stories.mjs create mode 100644 scripts/font-weight-policy.test.mjs diff --git a/AGENTS.md b/AGENTS.md index ad5ad15..a8c4f9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,14 @@ 이 파일을 먼저 읽고 필요한 항목만 탐색한다. 전체 문서·전체 소스·전체 Storybook을 반복해서 읽지 않는다. +## Typography — 공통 정책 + +- HMDesign의 Font Weight는 400(Regular), 500(Medium), 700(Bold)의 3단계만 사용한다. Font Weight 600은 사용하지 않으며, 카드 제목은 700을 기본으로 한다. +- 신규 컴포넌트·AI 생성 코드·Storybook에 동일하게 적용한다. 폰트 패밀리·크기·행간·자간과 기존 OrgChart 카드·연결선·배치는 유지한다. 외부 CSS 커스터마이징은 기본 정책과 구분한다. + ## AI 조직도 생성 — 공통 정책 +- 확장형 OrgChart의 조직 계층 생성 시 팀(Team)을 셀(Cell)의 직접 자식 또는 모든 하위 계층에 배치해서는 안 된다. 팀 → 셀은 허용한다. Storybook 샘플 및 AI가 생성하는 조직 데이터는 정의된 조직 위계를 따른다. 셀을 무조건 말단으로 처리하거나 다른 조직 유형의 제약을 추가하지 않는다. 상세 기준과 제공 데이터 충돌 처리는 [AI 생성 정책](docs/org-chart/ai-page-generation-rules.md#21-확장형-조직-위계-규칙)을 따른다. 이는 데이터 생성 정책이며 제품 런타임 Validator·이동 정책·공개 API 변경을 뜻하지 않는다. - OrgChart UI 수정 또는 AI 생성 작업은 [최종 디자인 승인 기록](docs/org-chart/design-approval.md)을 먼저 확인해 승인된 기본 디자인을 따른다. 기본값 변경·외부 테마 커스터마이징·데이터 생성의 구분과 접근성 미해결 상태는 해당 문서를 따른다. - 자연어 또는 JSON·CSV·Excel에서 **새 조직도 컴포넌트 또는 전체 페이지를 생성하는 작업**은 먼저 [AI 조직도 자동 생성 정책](docs/org-chart/ai-page-generation-rules.md)을 읽고 적용한다. 생성 방식 미지정 시 코드 작성 전에 위 정책의 사전 선택 질문으로 Chart Only / Full Page를 확인하고 답변 후 구현한다. 이미 명시한 방식은 다시 묻지 않는다. 회사·조직 유형·Depth·인원수를 특정 사례의 기본값으로 고정하지 않는다. - **Chart Only**는 기존 페이지에 삽입 가능한 독립 컴포넌트로 흰색 캔버스·조직도 노드·연결선, 최초 Fit to View·마우스 휠 확대/축소·Pan을 제공하며 외부 제목·통계·설명·조작 패널을 생성하지 않는다. **Full Page**는 기존 HM UI를 우선 재사용해 요청된 제목·정보·필터·컨트롤과 전체 배치·반응형을 구성한다. 두 방식 모두 기존 OrgChart Core·React Wrapper와 정확한 요청 구조·인원·데이터를 사용한다. Fixed·Auto/Expanded는 생성 방식 선택을 대신하지 않는다. Chart Only에는 아래 일반 업무 화면의 Navigation 의무 대신 해당 정책의 최소 화면 규칙을 적용하고, Full Page에는 기존 HM 화면 계약을 적용한다. 공개 API·Theme·React·접근성·검증 규칙은 계속 적용한다. diff --git a/CLAUDE.md b/CLAUDE.md index 3f16522..0d4e1e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,7 @@ shadcn/ui(Radix, Tailwind v4, CVA) 기반 HM 디자인 시스템 시제품. Reac ## 지침 문서 +- 확장형 OrgChart의 조직 계층 생성 시 팀(Team)은 셀(Cell)의 직접 자식 및 모든 하위 계층에 배치하지 않는다. 팀 → 셀은 허용하며 셀의 다른 하위 조직을 일괄 금지하지 않는다. Storybook 샘플과 AI 생성 데이터에 같은 기준을 적용한다. 다른 조직 유형의 기존 관계·공개 API·런타임 검증은 유지하며 [AI 생성 정책 §2.1](docs/org-chart/ai-page-generation-rules.md#21-확장형-조직-위계-규칙)을 따른다. 규칙의 기준은 아래 문서이며, 이 파일에는 규칙 본문을 복사하지 않는다. 작업 전에 해당 문서를 읽는다. - `AGENTS.md` — AI Implementation Defaults와 Navigation 필수 요약. 기존 Component / Storybook Reference 우선, Theme / Semantic 기본 적용, 단일 Navigation, 최소 Custom은 사용자의 별도 지시 없이 모든 신규·수정 UI에 적용한다. @@ -21,6 +22,8 @@ shadcn/ui(Radix, Tailwind v4, CVA) 기반 HM 디자인 시스템 시제품. Reac ## 반드시 지킬 것 (요약) +- HMDesign의 Font Weight는 400(Regular), 500(Medium), 700(Bold)의 3단계만 사용한다. Font Weight 600은 사용하지 않으며, 카드 제목은 700을 기본으로 한다. 신규 컴포넌트와 AI 생성 코드에도 적용하며, 상세 기준은 [테마·토큰 가이드](docs/guidelines/테마-토큰-가이드.md#font-weight-policy)를 따른다. + - 구현 우선순위는 기존 `@hm-design/ui` → 기존 Variant / Prop → 기존 Composition → shadcn → Radix Primitive → 최소 Custom이다. Visual은 처음부터 기존 Theme / Semantic / Component Mapping에 연결한다. 상세 탐색·범위 분류·예외 조건은 `docs/guidelines/shadcn-기반-구현-원칙.md`를 따른다. - Behavior / Structure는 shadcn/Radix를 유지하고 Visual은 HM Theme로 조정한다. 디자인 차이만으로 별도 Component, interaction logic 또는 Production API를 만들지 않는다. - HM이 값을 지정한 속성은 Theme Token으로, 지정하지 않은 속성은 shadcn 기본 구현 그대로 둔다. diff --git a/docs/components/card.md b/docs/components/card.md index e2bdba5..71747d0 100644 --- a/docs/components/card.md +++ b/docs/components/card.md @@ -9,7 +9,7 @@ | 항목 | 내용 | |---|---| | 구현 상태 | HM 스타일 적용 완료 (``만으로 적용, `hm-card` class 없음) | -| 기준일 | 2026-10-02 (문서·주석 정합성 정정 2026-10-08, 디자인 값 변경 없음) | +| 기준일 | 2026-10-02 (2026-10-08 문서 정정, 2026-10-14 Font Weight 3단계 통합) | | 기반 | shadcn/ui Card (div 기반, Radix 없음) | | 대표 Story | `Components/Data & Display/Card` → `All` (이름 "Card") | | 보류 | Eyebrow, Divider, Footer·Media, 반응형, 폭 (§11) | @@ -20,7 +20,7 @@ - **ui_viz 채택(SOURCE_ADOPTED, Canonical Pattern Q6 `YES`, 2026-09-28)**: border 1px, body-padding 24px 사방, header↔body 간격 24px(D-093 D-042/D-059), title↔action 간격 20px, 교차축 중앙 정렬, 상태 없음. 이 값들은 문서상 `Confirmed` 표기가 아니라 owner가 채택한 Canonical 값이다. HM Card는 이 채택 값을 적용한다. - **ui_viz LOCAL_EXCEPTION(컴포넌트 범위 값)**: border 색 `#DEDBD2`(전역 token 아님, Tabs·Search Field와 값만 같고 alias 금지), Beige border `#ECD4A2`(Primitive `hm-brown-light-4`와 같은 값). **현재 HM 구현은 이 두 값을 컴포넌트 전용 값으로 두지 않고 공유 Semantic Token에 연결한다.** 차이는 §6.1에 기록한다. - **ui_viz CANONICAL_OVERRIDE**: Title Source 19px/700 → `heading-md` 20/28/600. -- **HM 사용자 결정**: 없음. +- **HM 사용자 결정 (2026-10-14)**: Font Weight 400 / 500 / 700 통합. 카드 제목은 700이며 크기·행간·간격은 유지한다. 이전 ui_viz 600 근거와 과거 실측은 당시 기록이다. - **shadcn 유지**: 아래 §7. - **ui_viz 미정의**: Description, Footer, `size="sm"`, Media, Hover/Selected/Clickable, shadow. @@ -35,7 +35,7 @@ | Padding | 24px 사방 (Card 위/아래, Header·Content 좌우) | 채택 | | Header ↔ Content 간격 | 24px | 채택 (D-093) | | 자식 clipping | 하지 않음 (`overflow: visible`) | 채택 (Type B) | -| Title | 20px / 28px / 600, `--hm-sem-fg-primary` | CANONICAL_OVERRIDE | +| Title | 20px / 28px / 700, `--hm-sem-fg-primary` | HM 사용자 결정 (2026-10-14) | | Header 안 Title ↔ Action 간격 | 20px (`column-gap`) | 채택 | | Action 정렬 | Title 반대쪽 끝, 교차축 중앙 | 채택 | | Description | shadcn 기본 (`text-sm text-muted-foreground`), HM Card CSS 미적용. 사용 범위는 §5.1 | ui_viz 미정의 | @@ -118,14 +118,14 @@ semantic HTML(`div`), React composition(Header/Title/Description/Action/Content/ - `rounded-xl`, `bg-card`, `ring-1 ring-foreground/10`, `overflow-hidden`, 기본 `--card-spacing: 16px`을 TSX에서 제거하고 theme CSS(border, radius 4px, surface, 24px)가 담당. - ring 대신 실제 1px border 사용, shadow 없음. -- Title을 20/28/600으로 변경(`size="sm"` 제외). +- 기본 Title은 20/28/700으로 통합(2026-10-14). `size="sm"`도 굵기 700을 사용하며 기존 14px 크기와 행간은 유지한다. 이전 20/28/600 도입·실측은 §13의 당시 검증 기록으로 보존한다. - Header 열 간격 20px, Action 교차축 중앙 정렬. - Footer 아래 모서리를 컨테이너 radius(border 안쪽 3px)에 맞춤. Footer 표면은 shadcn 그대로다. - `surface` prop 추가. ### 8.1 Demo `.hm-panel`과 운영 Card 구분 -- **운영 Card**: `@hm-design/ui`의 ``와 `packages/ui/src/theme/card.css`. 이 문서 §3의 값(padding 24px, Title 20/28/600 등)과 대표 Story `All`이 기준이다. 소비 프로젝트는 `hm-panel*` class 없이 이 API만 쓴다. +- **운영 Card**: `@hm-design/ui`의 ``와 `packages/ui/src/theme/card.css`. 이 문서 §3의 값(padding 24px, Title 20/28/700 등)과 대표 Story `All`이 기준이다. 소비 프로젝트는 `hm-panel*` class 없이 이 API만 쓴다. - **과거 Demo Panel**: 공식 이전 전 UI Demo의 `src/App.tsx`에 있던 4개 Card에 붙은 `hm-panel`, `hm-panel-header`, `hm-panel-icon`, `hm-panel-content` class와 `src/hm-theme.css` 규칙. 패키지(`packages/ui`)에 포함되지 않으며 Card 규격 Reference가 아니다. - `.hm-panel`은 Card 외형(border, radius, background, shadow)을 지정하지 않는다. 다만 Demo class 묶음은 운영 Card 값을 다음처럼 덮어쓴다. @@ -133,7 +133,7 @@ semantic HTML(`div`), React composition(Header/Title/Description/Action/Content/ |---|---|---| | `.hm-panel` | `gap: 0`, `--card-spacing: 16px` | gap·padding 24px | | `.hm-panel-header` | 아이콘 34px 열 grid, `column-gap: 11px`, `border-bottom: 1px solid #ede9df`, `padding: 22px 23px 17px` | Header 좌우 24px, column-gap 20px, divider 없음 | -| `.hm-panel-header [data-slot="card-title"]` | 15px / 1.35 / 700, letter-spacing -.02em | 20 / 28 / 600 | +| `.hm-panel-header [data-slot="card-title"]` | 15px / 1.35 / 700, letter-spacing -.02em | 20 / 28 / 700 | | `.hm-panel-header [data-slot="card-description"]` | 11px, `--hm-muted`, margin-top 2px | shadcn 기본 `text-sm text-muted-foreground` | | `.hm-panel-icon` | 34px 아이콘 박스 | 해당 요소 없음 | | `.hm-panel-content` | `padding: 23px` | 좌우 24px | @@ -150,7 +150,7 @@ semantic HTML(`div`), React composition(Header/Title/Description/Action/Content/ ## 10. 사용자 확정 결정 -추가 사용자 결정 없음. +2026-10-14: 카드 제목 기본 굵기를 600에서 700으로 통합한다. `--hm-card-title-font-weight` 이름은 유지하며 `--hm-font-weight-bold`를 참조한다. `size="sm"` 제목도 700을 사용하며 크기와 행간은 기존 값을 유지한다. ## 11. 미확정 / 보류 diff --git a/docs/components/dialog.md b/docs/components/dialog.md index 833c430..2b03b6b 100644 --- a/docs/components/dialog.md +++ b/docs/components/dialog.md @@ -19,7 +19,7 @@ ## 2. 적용 근거 - **ui_viz Confirmed (Dialog-local)**: radius 8px(USER_DECISION 2026-09-17), shadow `0 0 12px rgba(0,0,0,.2)`(D-093 D-088), 내용 기준 크기와 최대 너비·높이 공식(D-088), Header `24/24/16`·Title↔Description 8·Body 24·Actions `0/24/24`·Action gap 8(D-093), Header 아래 inset divider 위치·두께와 Footer divider 없음, Body만 scroll, 긴 텍스트 줄바꿈, 표는 Body 안 가로 scroll, 표면 `color.bg.surface`·전경 `color.fg.primary`, Title/Description/Body 타이포. -- **CANONICAL_OVERRIDE**: Title `heading-md` 20/28 중 weight **700**은 Dialog Title에만 적용(D-006, 600 대체). Foundation `heading-md`는 600 그대로. +- **CANONICAL_OVERRIDE**: Title `heading-md` 20/28 중 weight **700**은 Dialog Title에만 적용(D-006, 600 대체). 이는 당시 ui_viz 근거다. 2026-10-14 HM 정책은 400 / 500 / 700이며 카드 제목도 700을 사용한다. Dialog Title의 700은 유지한다. - **현재 HM Actions**: Footer padding은 `16px 24px 24px`이며 divider는 없다. 위 ui_viz 도입 근거의 `0/24/24`와 구분한다. - **Temporary / TBD(적용했으나 확정값 아님)**: 외곽선·Header divider 색 `#DEDBD2`는 `Temporary`, Token relation TBD(DIALOG-10 FOLLOW_UP). 이전 확정 색(`#9A9284`/`#E0DACE`)은 대체됐고 D-093 실행 화면과 같은 값이라 현재 구현을 따랐다. 색 역할이 정해지기 전까지 Dialog 범위 값이다. - **PARTIAL / REFERENCE_ONLY(적용)**: Description 전경 `color.fg.secondary`(DIALOG-06 PARTIAL, 현재 style 보존). edge 16px(REVIEW). diff --git a/docs/guidelines/ai-screen-contract.md b/docs/guidelines/ai-screen-contract.md index 2a35d27..8720598 100644 --- a/docs/guidelines/ai-screen-contract.md +++ b/docs/guidelines/ai-screen-contract.md @@ -1,5 +1,7 @@ # AI 화면 선택·완료 계약 +> Typography: HMDesign의 Font Weight는 400(Regular), 500(Medium), 700(Bold)의 3단계만 사용한다. Font Weight 600은 사용하지 않으며, 카드 제목은 700을 기본으로 한다. 신규 화면과 컴포넌트 생성에도 적용한다. + 읽는 순서는 `AGENTS.md` → 아래 해당 기능 행 → [공개 인덱스](component-pattern-reference.md)의 선택 문서와 대표 All Story다. 과거 보고서·verification Story의 기능을 새 요구사항으로 복사하지 않는다. ## 구현 전 기록 diff --git a/docs/guidelines/테마-토큰-가이드.md b/docs/guidelines/테마-토큰-가이드.md index ba9390a..6f7c045 100644 --- a/docs/guidelines/테마-토큰-가이드.md +++ b/docs/guidelines/테마-토큰-가이드.md @@ -308,3 +308,16 @@ HMDesignSystem의 핵심 원칙은 다음과 같다. 현재 Storybook의 `Theme/Designer Playground/Editor`에서 JSON URL/파일을 Preview하고 상위 컬러 17개 역할을 편집·Reset·Export/Import할 수 있다. Component Token은 직접 수정하지 않는다. 운영 컬러 원본은 `themes/default.json`/서비스 JSON이며 package fallback CSS/데이터는 생성한다. Production은 `@hm-design/ui/theme`의 URL Loader/Apply를 사용한다. Theme artifact는 UI package와 별도 배포한다. 상세 절차/제한은 [디자인팀 Theme 가이드](designer-theme-guide.md), [Theme delivery](theme-delivery.md), Runtime 적용·제한은 [Runtime Theme](runtime-theme.md)를 참고한다. 기존 디자인 정책/과거 기록은 유지한다. + + +## Font Weight 정책 — 2026-10-14 + +HMDesign의 Font Weight는 400(Regular), 500(Medium), 700(Bold)의 3단계만 사용한다. Font Weight 600은 사용하지 않으며, 카드 제목은 700을 기본으로 한다. + +- 400: 본문·입력·기본 텍스트. 기존 `--hm-input-font-weight: 400`을 유지한다. +- 500: 버튼·필드 라벨·직위 배지·일반 강조. `--hm-font-weight-medium: 500`. +- 700: 제목·카드 제목·직원 이름·그룹 제목·강한 강조. `--hm-font-weight-bold: 700`. +- `--hm-card-title-font-weight`는 이름을 유지하고 `var(--hm-font-weight-bold)`를 참조한다. 기본 Card와 `size="sm"` Card 제목에 700을 적용한다. +- 신규 컴포넌트·Storybook·AI 생성 코드에 같은 정책을 적용한다. Tailwind `font-normal` / `font-medium` / `font-bold`만 사용하며 `font-semibold`나 750·800 등 추가 단계는 사용하지 않는다. +- 폰트 패밀리·크기·행간·자간은 변경하지 않는다. `@font-face`의 100–900은 가변 폰트의 지원 범위이며 UI 사용 단계가 아니다. 폰트 파일과 라이선스는 보존한다. +- Typography는 Runtime Theme JSON 공개 allowlist 입력이 아니다. 공개 API·외부 CSS 커스터마이징 계약은 유지하며 과거 보고서의 600 실측값은 당시 검증 기록으로 보존한다. diff --git a/docs/org-chart/ai-page-generation-rules.md b/docs/org-chart/ai-page-generation-rules.md index 14951ce..46c68a3 100644 --- a/docs/org-chart/ai-page-generation-rules.md +++ b/docs/org-chart/ai-page-generation-rules.md @@ -56,6 +56,17 @@ Codex·Claude가 조직도 생성 요청을 받았을 때 **생성 방식이 명 Depth에 회사나 직원 카드를 포함하는지, 조직별 인원이 직접 소속인지 하위 조직 포함인지 구분한다. 의미가 불분명해 구조나 총원이 달라지는 경우 해당 항목만 질문한다. 내부 렌더러의 0부터 시작하는 인덱스를 사용자 Depth와 혼동하지 않는다. +### 2.1 확장형 조직 위계 규칙 + +확장형 OrgChart의 조직 계층 생성 시 팀(Team)을 셀(Cell)의 하위에 배치해서는 안 된다. Storybook 샘플 및 AI가 생성하는 조직 데이터는 정의된 조직 위계를 따라야 한다. + +- 셀 → 팀 직접 관계와 셀의 모든 하위 계층에 나타나는 팀을 금지한다. 팀의 전체 부모 경로를 확인하며 중간 조직이 있어도 같은 규칙을 적용한다. +- 팀 → 셀은 허용한다. 셀을 무조건 말단으로 처리하지 않으며 다른 하위 조직의 허용 여부는 기존 정책을 따른다. +- 현재 type은 `root | division | office | div | cell | team`이다. enum 선언 순서는 허용 위계의 순서가 아니다. 그 외 유형의 기존 부모·자식 관계를 유지하고 새 유형·제약·필수 중간 계층을 임의 추가하지 않는다. +- TB / LR / Hybrid는 동일한 `parentId` 데이터의 표시 방식이다. 레이아웃에 따라 위계를 변경하지 않으며 팀·셀을 모든 예제에 필수로 생성하지 않는다. +- 제공 JSON·CSV·Excel의 관계가 이 규칙과 충돌하면 원본 경로·ID와 충돌을 보고하고 필요한 수정 조건을 확인한다. 원본 부모·유형을 조용히 치환하거나 데이터를 삭제하지 않는다. +- 이 규칙은 Storybook 샘플·AI 데이터 생성 및 사전 정합성 확인에 적용한다. 현재 제품 Validator·Drag & Drop 이동 정책의 자동 거절을 보장하지 않으며 런타임 검증 기능·공개 API 변경에는 별도 승인이 필요하다. + ## 3. 자연어에서 가상 데이터 생성 - 명시된 회사·조직명·관계·유형·조직 수·직원 수·직위·대표이사 유무를 정확히 반영한다. 합계가 충돌하거나 관계를 결정할 수 없으면 생성 전에 필요한 조건만 질문한다. diff --git a/docs/org-chart/design-approval.md b/docs/org-chart/design-approval.md index 5362f5a..f9f6cb9 100644 --- a/docs/org-chart/design-approval.md +++ b/docs/org-chart/design-approval.md @@ -65,6 +65,8 @@ Storybook 4개 화면과 Chart Only를 포함한다. 기존 NOT VERIFIED인 브 ### Chart Only — React / Vanilla +**2026-10-14 Font Weight 통합 사용자 요청:** HMDesign 기본 굵기는 400(Regular) / 500(Medium) / 700(Bold)의 3단계다. Fixed 직원 이름·그룹 제목은 600→700, 직원 직위 배지는 600→500, 중앙 허브 제목은 750→700으로 통합한다. Expanded의 기존 400 / 500 / 700은 유지한다. 폰트 패밀리·크기·행간·자간, 카드 크기·색상·간격·연결선·배치와 조직 데이터·기능은 유지한다. 이 요청은 기존 접근성 미해결 사항이나 전체 회귀 검증 상태를 변경하지 않는다. + Auto/Expanded Chart Only의 승인 기준은 다음과 같다. - 흰색 캔버스, 외부 제목·설명·요약·툴바 없음. @@ -123,6 +125,7 @@ Cell Border Only 디자인 실험은 사용자 결정으로 정식 적용에서 1. 승인된 기존 디자인을 기본 기준으로 사용하고 카드·색상·Typography·간격을 임의 변경하지 않는다. 2. 사용자 요청에 따른 조직 계층·직원 데이터 생성은 계속 허용하며 정확한 요청 구조·인원·원본 데이터를 보존한다. + - 확장형 OrgChart의 Storybook 샘플·AI 생성 데이터는 셀(Cell)의 직접 자식 및 모든 하위 계층에 팀(Team)을 배치하지 않는다. 팀 → 셀은 허용하며 다른 유형의 기존 관계와 셀의 다른 하위 조직 허용 여부는 유지한다. [AI 생성 정책 §2.1](ai-page-generation-rules.md#21-확장형-조직-위계-규칙)을 따른다. 이는 승인된 디자인 변경이나 제품 런타임 검증·공개 API 변경 승인이 아니다. 3. 외부 CSS 테마를 통한 의도적인 커스터마이징은 허용한다. 기본값 자체 변경과 구분한다. 4. 승인된 기본값 자체를 변경할 때는 사전 사용자 확인을 받는다. 이미 해당 변경을 명시적으로 승인받은 요청은 같은 확인을 반복하지 않는다. 5. 접근성 미해결 사항을 숨기거나 PASS로 변경하지 않는다. diff --git a/docs/org-chart/expanded-usage.md b/docs/org-chart/expanded-usage.md index cee1cbc..62d3742 100644 --- a/docs/org-chart/expanded-usage.md +++ b/docs/org-chart/expanded-usage.md @@ -45,6 +45,8 @@ file:// Fetch는 지원하지 않는다. 이번 작업에 외부 API·서버·DB 기존 JSON 예시는 Small 20명/3회사, Medium 100명/5회사, Large 300명/7회사다. P7의 7회사 합산·회사별 선택은 Large를 기반으로 아래 공개 helper를 사용한다. +Storybook·AI 생성 데이터의 조직 위계는 [AI 생성 정책 §2.1](ai-page-generation-rules.md#21-확장형-조직-위계-규칙)을 따른다. 셀의 직접 자식 및 모든 하위 계층에 팀을 배치하지 않으며 팀 → 셀은 허용한다. 셀을 일괄 말단으로 제한하지 않는다. fixture 생성기는 Large의 기존 ID·직원·분배를 유지하면서 `실 → 팀 → 셀`과 실 아래 형제 협력팀을 만든다. Small/Medium은 기존 구조를 유지한다. TB / LR / Hybrid는 같은 위계를 표시하고 helper는 template의 위계를 보존한다. 제품 Validator·Drag & Drop의 새 계층 거절 기능은 이번 데이터 정책에 포함되지 않는다. + ## React ```tsx diff --git a/docs/org-chart/org-chart-usage-guide.md b/docs/org-chart/org-chart-usage-guide.md index 21e6daa..b8812e2 100644 --- a/docs/org-chart/org-chart-usage-guide.md +++ b/docs/org-chart/org-chart-usage-guide.md @@ -568,7 +568,7 @@ nullable 필드는 생략하거나 빈 문자열로 대신하지 않는다. 필 ### 깊은 JSON: 회사 → 부문 → 본부 → 부서 → 팀 → 직원 -현재 Auto의 트리는 enum 순서에 종속되지 않고 실제 parentId 관계를 따른다. 다음은 5개 조직 계층 + 직원 카드 예제다. 본부=`office`, 부서=`div` 대응은 이 예제의 변환 선택이며 일반 기업 의미를 강제하지 않는다. +현재 Auto의 트리는 enum 순서에 종속되지 않고 실제 parentId 관계를 따른다. **확장형 조직 데이터 생성 시 셀(Cell)의 직접 자식 또는 모든 하위 계층에 팀(Team)을 배치하지 않는다. 팀 → 셀은 허용한다.** 셀을 무조건 말단으로 처리하지 않으며 다른 유형의 기존 관계는 유지한다. 이 데이터 생성 규칙은 현재 Validator·Drag & Drop의 런타임 계층 거절 기능을 의미하지 않는다. 제공 데이터와 충돌하면 원본을 보존하고 수정 조건을 확인한다. [AI 생성 정책 §2.1](ai-page-generation-rules.md#21-확장형-조직-위계-규칙)을 따른다. 다음은 5개 조직 계층 + 직원 카드 예제다. 본부=`office`, 부서=`div` 대응은 이 예제의 변환 선택이며 일반 기업 의미를 강제하지 않는다. ```json { @@ -657,7 +657,7 @@ Fixed는 별도의 기존 부서 ID별 스타일·SVG를 사용한다. 회사 ### 카드와 글자 - Fixed의 기존 대표자 SVG는 146×74, 1Depth 조직장 SVG는 146×68, 더 깊은 조직장/일반 직원의 small SVG는 108×52다. 등록 부서 자산이 없는 조직은 기존 fallback 카드다. 모든 Fixed 데이터에 SVG 매핑이 있는 것은 아니다. -- Fixed SVG 직위/이름 기본 크기는 small 15/18px, 대표자 20/24px, 부서장 SVG 직위 18px·이름 24px다. 이름 weight 600, 직위 400이며 전역 서체는 Noto Sans KR 등 기존 fallback을 따른다. 재설계 규격으로 사용하지 않는다. +- Fixed SVG 직위/이름 기본 크기는 small 15/18px, 대표자 20/24px, 부서장 SVG 직위 18px·이름 24px다. 이름 weight 700(2026-10-14 Font Weight 통합), 직위 400이며 전역 서체는 Noto Sans KR 등 기존 fallback을 따른다. 재설계 규격으로 사용하지 않는다. - Auto는 SVG 배경 대신 기존 중립 카드와 회사 강조 Border를 쓴다. 일반 직원 폭 114px·최소 높이 54px, 조직장/상단 카드 폭 154px·최소 높이 72px, 사진 36×36, padding 8px, Border 1px, inset 강조 3px, 이름/직위 13/10px다. 긴 텍스트는 실측 높이가 늘 수 있다. - Auto의 Root 대표자와 하위 조직장 카드는 같은 leader geometry를 사용한다. 1Depth/2Depth별 새 디자인 prop은 없다. 3글자 한글 이름은 표시 계층에서 띄어 쓰고 직위 공백을 표시상 제거하지만 원본과 callback 데이터는 유지한다. - Auto 일반 카드에서 jobTitle·근무 상태를 별도 행으로 출력하지 않는 경로가 있다. 팝업은 원본 jobTitle/workStatus를 보여준다. Fixed의 파견/휴직 보조 카드·요약과 Auto의 직원 카드를 혼동하지 않는다. @@ -785,6 +785,8 @@ export function UpdatingChart({ dataset }: { dataset: OrgChartDataset }) { 공개 `createOrgChartExampleDataset`는 기존 template에서 `employeeCount: 20 | 100 | 300`, scope `total | per-company`로 가상 예시를 만드는 helper다. 임의 500명 생성·실제 Excel 변환 API가 아니다. 회사별 모드는 선택 회사마다 N명이며 total 모드는 원래 회사별 분배 후 선택 회사만 필터링한다. 실제 사용자 데이터를 이 helper로 재생성하지 않는다. +Expanded Storybook은 `examples/org-chart/create-expanded-fixtures.mjs`가 생성한 Large template을 위 helper로 재사용한다. Large의 분기는 `회사(root) → 본부(division) → 실(office) → 팀(team) → 셀(cell)`이며 협력팀은 같은 실 아래 형제 팀이다. Small/Medium은 기존 규모·유형을 유지하고 팀을 강제로 추가하지 않는다. helper는 입력 template의 위계를 보존하므로 호출자는 template 자체의 셀 하위 팀 여부도 확인해야 한다. TB / LR / Hybrid가 위계를 각각 생성하지 않는다. + ### Chart Only 정책과 현재 기능의 관계 [AI 페이지 생성 정책](ai-page-generation-rules.md)을 먼저 읽고 AGENTS.md·CLAUDE.md의 연결을 따른다. **생성 방식이 미지정이면 코드 작성 전에 Chart Only / Full Page를 질문하고 답변 후 구현한다.** Chart Only를 선택하면 조직도 캔버스만 표시하고 외부 제목·통계·설명·조작 패널을 만들지 않는다. Full Page를 선택하면 기존 HM UI와 OrgChart Core·React Wrapper로 요청된 UI와 전체 레이아웃을 구성한다. 이미 명시된 방식은 다시 묻지 않으며 Fixed·Auto는 생성 방식 선택을 대신하지 않는다. diff --git a/examples/org-chart-react/verify-hierarchy-stories.mjs b/examples/org-chart-react/verify-hierarchy-stories.mjs new file mode 100644 index 0000000..4e98d39 --- /dev/null +++ b/examples/org-chart-react/verify-hierarchy-stories.mjs @@ -0,0 +1,146 @@ +// Actual Expanded Storybook UI: seven companies x three sizes x three layouts. +// Build Storybook first; uses the existing optional Playwright/Chrome runtime. +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const output = path.resolve('test-results/org-chart-hierarchy'); +const directory = path.join(output, 'storybook'); +const template = JSON.parse(await fs.readFile('examples/org-chart/data-expanded-large.json', 'utf8')); +const companies = template.affiliations; +const mime = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }; +const server = http.createServer(async (request, response) => { + try { + const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); + if (pathname === '/favicon.ico') return response.writeHead(204).end(); + const file = path.resolve(directory, '.' + (pathname === '/' ? '/index.html' : pathname)); + const relative = path.relative(directory, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); + response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }).end(await fs.readFile(file)); + } catch { response.writeHead(404).end(); } +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +let browser, failure; +// Resume only complete company groups after a test harness interruption. +const savedChecks = process.argv.includes('--resume') + ? JSON.parse(await fs.readFile(path.join(output, 'browser-progress.json'), 'utf8')).checks : []; +const checks = savedChecks.filter(check => savedChecks.filter(row => row.company === check.company).length === 9); +const errors = []; +try { + browser = await launchChromium({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + const settle = () => page.evaluate(async () => { + await document.fonts.ready; + for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); + }); + const ready = async count => { + await page.waitForFunction(count => { + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return shadow?.querySelector('.hm-orgchart-shell[aria-busy="false"]') + && (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count) + && [...shadow.querySelectorAll('link')].every(link => link.sheet); + }, count); + await settle(); + }; + const select = async (label, option) => { + await page.getByRole('combobox', { name: label, exact: true }).click(); + await page.getByRole('option', { name: option, exact: true }).click(); + }; + const snapshot = () => page.locator('.hm-orgchart-host').evaluate(host => { + const shadow = host.shadowRoot; + const box = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; }; + return { + nodes: [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => ({ id: node.dataset.organizationId, parentId: node.dataset.parentId || null, type: node.dataset.organizationType })), + employees: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, organizationId: card.closest('.node').dataset.organizationId })), + viewport: box(shadow.querySelector('.hm-orgchart-viewport')), + forest: box(shadow.querySelector('.hm-orgchart-forest')), + contentBounds: (() => { + const boxes = [...shadow.querySelectorAll('.hm-orgchart-organization, .hm-orgchart-organization-connectors path')].filter(element => element.getClientRects().length).map(box); + const x = Math.min(...boxes.map(b => b.x)), y = Math.min(...boxes.map(b => b.y)); + return { x, y, width: Math.max(...boxes.map(b => b.x + b.width)) - x, height: Math.max(...boxes.map(b => b.y + b.height)) - y }; + })(), + layout: shadow.querySelector('.hm-orgchart-expanded').dataset.autoLayout, + paths: [...shadow.querySelectorAll('.hm-orgchart-organization-connectors path')].map(p => ({ id: p.dataset.organizationId, parentId: p.dataset.parentId, d: p.getAttribute('d') })), + upstreamConnectors: [...shadow.querySelectorAll('.hierarchy, .node[data-depth]')].flatMap(element => ['::before', '::after'].map(pseudo => { + const style = getComputedStyle(element, pseudo); + return style.content !== 'none' && style.display !== 'none' && [style.borderTopWidth, style.borderLeftWidth, style.borderRightWidth, style.borderBottomWidth].some(width => parseFloat(width) > 0); + })).filter(Boolean).length, + }; + }); + const fit = async () => { + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); + await settle(); + const { viewport: v, contentBounds: f } = await snapshot(); + assert.ok(f.x >= v.x - 1 && f.y >= v.y - 1 && f.x + f.width <= v.x + v.width + 1 && f.y + f.height <= v.y + v.height + 1, `complete forest fits viewport: ${JSON.stringify({ viewport: v, forest: f })}`); + }; + await page.goto(`http://127.0.0.1:${server.address().port}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); + await ready(140); + let previousCompany; + for (const company of companies) { + if (checks.filter(check => check.company === company.id).length === 9) continue; + await page.getByRole('button', { name: /회사 선택/ }).click(); + await page.getByRole('checkbox', { name: previousCompany?.name ?? '전체 선택', exact: true }).uncheck(); + await page.getByRole('checkbox', { name: company.name, exact: true }).check(); + await page.keyboard.press('Escape'); + previousCompany = company; + for (const count of [20, 100, 300]) { + await select('회사당 직원 수', `${count}명`); + await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); + await ready(count); + const expected = template.organizations.filter(org => org.companyId === company.id).map(org => ({ id: org.id, parentId: org.parentId, type: org.type })).sort((a, b) => a.id.localeCompare(b.id)); + let employees; + for (const [type, label] of [['tb', '세로형 TB'], ['lr', '가로형 LR'], ['hybrid', '혼합형 Hybrid']]) { + await select('조직도 표시 유형', label); + await ready(count); + const rendered = await snapshot(); + assert.equal(rendered.layout, type); + assert.deepEqual(rendered.nodes.sort((a, b) => a.id.localeCompare(b.id)), expected); + const byId = new Map(rendered.nodes.map(org => [org.id, org])); + for (const team of rendered.nodes.filter(org => org.type === 'team')) { + let ancestor = byId.get(team.parentId); + while (ancestor) { assert.notEqual(ancestor.type, 'cell'); ancestor = byId.get(ancestor.parentId); } + } + const roster = rendered.employees.sort((a, b) => a.id.localeCompare(b.id)); + assert.equal(new Set(roster.map(employee => employee.id)).size, count); + assert.ok(roster.every(employee => byId.has(employee.organizationId))); + if (employees) assert.deepEqual(roster, employees); else employees = roster; + if (type === 'tb') assert.ok(rendered.upstreamConnectors > 0, 'TB engine CSS connectors are present'); + else { + assert.equal(rendered.paths.length, expected.filter(org => org.parentId).length); + assert.ok(rendered.paths.every(p => p.d && !/NaN|Infinity/.test(p.d) && byId.get(p.id)?.parentId === p.parentId), 'measured connectors follow parent IDs and have finite geometry'); + } + await fit(); + const teamId = `${company.id}-team-0`, cellId = `${company.id}-cell-0`; + const toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${teamId}"]`); + await toggle.focus(); await toggle.press('Enter'); await ready(); + assert.equal(await page.locator(`.node[data-organization-id="${cellId}"]`).count(), 0); + assert.ok((await snapshot()).employees.length < count); + await fit(); + await toggle.focus(); await toggle.press('Enter'); await ready(count); + assert.equal(await page.locator(`.node[data-organization-id="${cellId}"]`).count(), 1); + assert.deepEqual((await snapshot()).employees.sort((a, b) => a.id.localeCompare(b.id)), employees); + await fit(); + checks.push({ company: company.id, employeeCount: count, layout: type, hierarchy: 'PASS', employeeIdentity: 'PASS', connectors: 'PASS', collapseExpand: 'PASS', fit: 'PASS' }); + await fs.writeFile(path.join(output, 'browser-progress.json'), JSON.stringify({ checks, errors }, null, 2)); + if (company.id === 'hanmac' && count === 300) await page.screenshot({ path: path.join(output, `storybook-${type}-300.png`), fullPage: true }); + } + } + console.log(`${company.name}: 9 combinations PASS`); + } + assert.deepEqual(errors, []); + assert.equal(await page.getByRole('alert').count(), 0); + assert.equal(checks.length, companies.length * 9); + assert.equal(new Set(checks.map(check => `${check.company}/${check.employeeCount}/${check.layout}`)).size, companies.length * 9); +} catch (error) { + failure = error.stack; + throw error; +} finally { + await fs.writeFile(path.join(output, 'browser-result.json'), JSON.stringify({ status: failure ? 'FAIL' : 'PASS', checks, errors, ...(failure ? { failure } : {}) }, null, 2)); + await browser?.close(); + await new Promise(resolve => server.close(resolve)); +} +console.log(`Expanded Storybook: ${checks.length}/63 combinations PASS; ${errors.length} browser errors`); diff --git a/examples/org-chart/create-expanded-fixtures.mjs b/examples/org-chart/create-expanded-fixtures.mjs index 8f37638..c4db366 100644 --- a/examples/org-chart/create-expanded-fixtures.mjs +++ b/examples/org-chart/create-expanded-fixtures.mjs @@ -23,10 +23,12 @@ for (const [size, count, companyCount, depth] of [['small', 20, 3, 2], ['medium' if (count > 20) division.leaderId = addEmployee(division, '본부장'); const office = org(`office-${branch}`, division.id, `가상 ${branch + 1}실`, 'office', 0); if (depth >= 3) { - const cell = org(`cell-${branch}`, office.id, `가상 ${branch + 1}셀`, 'cell', 0); + // Keep record order/IDs and weighted employee placement stable. Large + // examples use office -> team -> cell; no team may descend from a cell. + org(`cell-${branch}`, depth >= 4 ? `${companyId}-team-${branch}` : office.id, `가상 ${branch + 1}셀`, 'cell', 0); if (depth >= 4) { - org(`team-${branch}`, cell.id, `가상 ${branch + 1}팀`, 'team', 0); - if (branch === 0) org('team-sibling', cell.id, '가상 협력팀', 'team', 1); + org(`team-${branch}`, office.id, `가상 ${branch + 1}팀`, 'team', 0); + if (branch === 0) org('team-sibling', office.id, '가상 협력팀', 'team', 1); } } if (count > 20 && branch === 0) org('office-sibling', division.id, '가상 계획실', 'office', 1); diff --git a/examples/org-chart/data-expanded-large.json b/examples/org-chart/data-expanded-large.json index 0364465..54e6407 100644 --- a/examples/org-chart/data-expanded-large.json +++ b/examples/org-chart/data-expanded-large.json @@ -31,7 +31,7 @@ { "id": "hanmac-cell-0", "companyId": "hanmac", - "parentId": "hanmac-office-0", + "parentId": "hanmac-team-0", "name": "가상 1셀", "type": "cell", "leaderId": null, @@ -40,7 +40,7 @@ { "id": "hanmac-team-0", "companyId": "hanmac", - "parentId": "hanmac-cell-0", + "parentId": "hanmac-office-0", "name": "가상 1팀", "type": "team", "leaderId": null, @@ -49,7 +49,7 @@ { "id": "hanmac-team-sibling", "companyId": "hanmac", - "parentId": "hanmac-cell-0", + "parentId": "hanmac-office-0", "name": "가상 협력팀", "type": "team", "leaderId": null, @@ -85,7 +85,7 @@ { "id": "hanmac-cell-1", "companyId": "hanmac", - "parentId": "hanmac-office-1", + "parentId": "hanmac-team-1", "name": "가상 2셀", "type": "cell", "leaderId": null, @@ -94,7 +94,7 @@ { "id": "hanmac-team-1", "companyId": "hanmac", - "parentId": "hanmac-cell-1", + "parentId": "hanmac-office-1", "name": "가상 2팀", "type": "team", "leaderId": null, @@ -130,7 +130,7 @@ { "id": "saman-cell-0", "companyId": "saman", - "parentId": "saman-office-0", + "parentId": "saman-team-0", "name": "가상 1셀", "type": "cell", "leaderId": null, @@ -139,7 +139,7 @@ { "id": "saman-team-0", "companyId": "saman", - "parentId": "saman-cell-0", + "parentId": "saman-office-0", "name": "가상 1팀", "type": "team", "leaderId": null, @@ -148,7 +148,7 @@ { "id": "saman-team-sibling", "companyId": "saman", - "parentId": "saman-cell-0", + "parentId": "saman-office-0", "name": "가상 협력팀", "type": "team", "leaderId": null, @@ -184,7 +184,7 @@ { "id": "saman-cell-1", "companyId": "saman", - "parentId": "saman-office-1", + "parentId": "saman-team-1", "name": "가상 2셀", "type": "cell", "leaderId": null, @@ -193,7 +193,7 @@ { "id": "saman-team-1", "companyId": "saman", - "parentId": "saman-cell-1", + "parentId": "saman-office-1", "name": "가상 2팀", "type": "team", "leaderId": null, @@ -229,7 +229,7 @@ { "id": "jangheon-industry-cell-0", "companyId": "jangheon-industry", - "parentId": "jangheon-industry-office-0", + "parentId": "jangheon-industry-team-0", "name": "가상 1셀", "type": "cell", "leaderId": null, @@ -238,7 +238,7 @@ { "id": "jangheon-industry-team-0", "companyId": "jangheon-industry", - "parentId": "jangheon-industry-cell-0", + "parentId": "jangheon-industry-office-0", "name": "가상 1팀", "type": "team", "leaderId": null, @@ -247,7 +247,7 @@ { "id": "jangheon-industry-team-sibling", "companyId": "jangheon-industry", - "parentId": "jangheon-industry-cell-0", + "parentId": "jangheon-industry-office-0", "name": "가상 협력팀", "type": "team", "leaderId": null, @@ -283,7 +283,7 @@ { "id": "jangheon-industry-cell-1", "companyId": "jangheon-industry", - "parentId": "jangheon-industry-office-1", + "parentId": "jangheon-industry-team-1", "name": "가상 2셀", "type": "cell", "leaderId": null, @@ -292,7 +292,7 @@ { "id": "jangheon-industry-team-1", "companyId": "jangheon-industry", - "parentId": "jangheon-industry-cell-1", + "parentId": "jangheon-industry-office-1", "name": "가상 2팀", "type": "team", "leaderId": null, @@ -328,7 +328,7 @@ { "id": "jangheon-cell-0", "companyId": "jangheon", - "parentId": "jangheon-office-0", + "parentId": "jangheon-team-0", "name": "가상 1셀", "type": "cell", "leaderId": null, @@ -337,7 +337,7 @@ { "id": "jangheon-team-0", "companyId": "jangheon", - "parentId": "jangheon-cell-0", + "parentId": "jangheon-office-0", "name": "가상 1팀", "type": "team", "leaderId": null, @@ -346,7 +346,7 @@ { "id": "jangheon-team-sibling", "companyId": "jangheon", - "parentId": "jangheon-cell-0", + "parentId": "jangheon-office-0", "name": "가상 협력팀", "type": "team", "leaderId": null, @@ -382,7 +382,7 @@ { "id": "jangheon-cell-1", "companyId": "jangheon", - "parentId": "jangheon-office-1", + "parentId": "jangheon-team-1", "name": "가상 2셀", "type": "cell", "leaderId": null, @@ -391,7 +391,7 @@ { "id": "jangheon-team-1", "companyId": "jangheon", - "parentId": "jangheon-cell-1", + "parentId": "jangheon-office-1", "name": "가상 2팀", "type": "team", "leaderId": null, @@ -427,7 +427,7 @@ { "id": "ptc-cell-0", "companyId": "ptc", - "parentId": "ptc-office-0", + "parentId": "ptc-team-0", "name": "가상 1셀", "type": "cell", "leaderId": null, @@ -436,7 +436,7 @@ { "id": "ptc-team-0", "companyId": "ptc", - "parentId": "ptc-cell-0", + "parentId": "ptc-office-0", "name": "가상 1팀", "type": "team", "leaderId": null, @@ -445,7 +445,7 @@ { "id": "ptc-team-sibling", "companyId": "ptc", - "parentId": "ptc-cell-0", + "parentId": "ptc-office-0", "name": "가상 협력팀", "type": "team", "leaderId": null, @@ -481,7 +481,7 @@ { "id": "ptc-cell-1", "companyId": "ptc", - "parentId": "ptc-office-1", + "parentId": "ptc-team-1", "name": "가상 2셀", "type": "cell", "leaderId": null, @@ -490,7 +490,7 @@ { "id": "ptc-team-1", "companyId": "ptc", - "parentId": "ptc-cell-1", + "parentId": "ptc-office-1", "name": "가상 2팀", "type": "team", "leaderId": null, @@ -526,7 +526,7 @@ { "id": "halla-cell-0", "companyId": "halla", - "parentId": "halla-office-0", + "parentId": "halla-team-0", "name": "가상 1셀", "type": "cell", "leaderId": null, @@ -535,7 +535,7 @@ { "id": "halla-team-0", "companyId": "halla", - "parentId": "halla-cell-0", + "parentId": "halla-office-0", "name": "가상 1팀", "type": "team", "leaderId": null, @@ -544,7 +544,7 @@ { "id": "halla-team-sibling", "companyId": "halla", - "parentId": "halla-cell-0", + "parentId": "halla-office-0", "name": "가상 협력팀", "type": "team", "leaderId": null, @@ -580,7 +580,7 @@ { "id": "halla-cell-1", "companyId": "halla", - "parentId": "halla-office-1", + "parentId": "halla-team-1", "name": "가상 2셀", "type": "cell", "leaderId": null, @@ -589,7 +589,7 @@ { "id": "halla-team-1", "companyId": "halla", - "parentId": "halla-cell-1", + "parentId": "halla-office-1", "name": "가상 2팀", "type": "team", "leaderId": null, @@ -625,7 +625,7 @@ { "id": "baron-cell-0", "companyId": "baron", - "parentId": "baron-office-0", + "parentId": "baron-team-0", "name": "가상 1셀", "type": "cell", "leaderId": null, @@ -634,7 +634,7 @@ { "id": "baron-team-0", "companyId": "baron", - "parentId": "baron-cell-0", + "parentId": "baron-office-0", "name": "가상 1팀", "type": "team", "leaderId": null, @@ -643,7 +643,7 @@ { "id": "baron-team-sibling", "companyId": "baron", - "parentId": "baron-cell-0", + "parentId": "baron-office-0", "name": "가상 협력팀", "type": "team", "leaderId": null, @@ -679,7 +679,7 @@ { "id": "baron-cell-1", "companyId": "baron", - "parentId": "baron-office-1", + "parentId": "baron-team-1", "name": "가상 2셀", "type": "cell", "leaderId": null, @@ -688,7 +688,7 @@ { "id": "baron-team-1", "companyId": "baron", - "parentId": "baron-cell-1", + "parentId": "baron-office-1", "name": "가상 2팀", "type": "team", "leaderId": null, diff --git a/examples/org-chart/verify-card-content.mjs b/examples/org-chart/verify-card-content.mjs index eefc964..989912b 100644 --- a/examples/org-chart/verify-card-content.mjs +++ b/examples/org-chart/verify-card-content.mjs @@ -96,7 +96,7 @@ function verifyTypography(value, label) { 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' + check(cards.every(card => card.person.color === 'rgb(7, 29, 58)' && card.person.fontSize === (small ? '20px' : '24px') && card.person.fontWeight === '700' && 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`); diff --git a/examples/org-chart/verify-final-card-check.mjs b/examples/org-chart/verify-final-card-check.mjs index 617517b..78a68ee 100644 --- a/examples/org-chart/verify-final-card-check.mjs +++ b/examples/org-chart/verify-final-card-check.mjs @@ -95,7 +95,7 @@ async function capture(story, scenario, data) { 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.badge && card.size !== 'representative') check(card.badge.fontSize === (card.size === 'leader' ? '9px' : '8.5px') && card.badge.fontWeight === (card.size === 'leader' ? '700' : '500'), `${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); diff --git a/examples/org-chart/verify-p3.1.mjs b/examples/org-chart/verify-p3.1.mjs index 6106504..0c0c83f 100644 --- a/examples/org-chart/verify-p3.1.mjs +++ b/examples/org-chart/verify-p3.1.mjs @@ -109,9 +109,9 @@ function verify(m, route, v) { 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.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 === '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.hub.font.fontSize === '21px' && m.hub.font.fontWeight === '700' && 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); diff --git a/examples/org-chart/verify-p3.mjs b/examples/org-chart/verify-p3.mjs index 17ecfef..171feac 100644 --- a/examples/org-chart/verify-p3.mjs +++ b/examples/org-chart/verify-p3.mjs @@ -191,7 +191,7 @@ function verify(value, label, viewport) { 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'); + return style.borderTopWidth === '1px' && style.borderRadius === '4px' && style.letterSpacing === value.zeroSpacingSerialization.computed && style.fontSize === (small ? '8.5px' : '9px') && style.fontWeight === (small ? '500' : '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 }))); @@ -208,9 +208,9 @@ function verify(value, label, viewport) { 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'); + return expected?.every(color => header.style.backgroundImage.includes(color)) && header.style.fontSize === (hr ? '18px' : '20px') && header.style.fontWeight === '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.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 === '700' && 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 }); diff --git a/packages/org-chart-react/src/HMEmployeeDialog.tsx b/packages/org-chart-react/src/HMEmployeeDialog.tsx index 1308654..8c8fea7 100644 --- a/packages/org-chart-react/src/HMEmployeeDialog.tsx +++ b/packages/org-chart-react/src/HMEmployeeDialog.tsx @@ -63,7 +63,7 @@ export default function HMEmployeeDialog({ selection, onClose }: OrgChartEmploye {employee?.name.slice(0, 1)}
{fields.filter(([, value]) => Boolean(value)).map(([label, value]) =>
-
{label}
{value}
+
{label}
{value}
)}
diff --git a/packages/org-chart/src/data/examples.test.ts b/packages/org-chart/src/data/examples.test.ts index aedbb4e..640928a 100644 --- a/packages/org-chart/src/data/examples.test.ts +++ b/packages/org-chart/src/data/examples.test.ts @@ -1,6 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fixture from '../../../../examples/org-chart/data-expanded-large.json'; +import small from '../../../../examples/org-chart/data-expanded-small.json'; +import medium from '../../../../examples/org-chart/data-expanded-medium.json'; import { companyColorPresets, type CompanyId } from '../company-color-presets.js'; import { calculateSummary, createOrgChartExampleDataset, JsonOrgChartDataSource, @@ -20,17 +22,34 @@ function assertContract(dataset: OrgChartDataset) { assert.equal(new Set(dataset.affiliations.map(affiliation => affiliation.id)).size, dataset.affiliations.length); const summary = calculateSummary(dataset).raw; assert.equal(summary.total, dataset.employees.length, 'leaders count once as employees'); - const pending = normalizeOrgChartDataset(dataset).roots.map(node => ({ node, depth: 1 })); + const pending = normalizeOrgChartDataset(dataset).roots.map(node => ({ node, depth: 1, underCell: false })); let maximumDepth = 0; while (pending.length) { - const { node, depth } = pending.pop()!; + const { node, depth, underCell } = pending.pop()!; + assert.ok(!(underCell && node.organization.type === 'team'), `team ${node.organization.id} must not descend from a cell`); maximumDepth = Math.max(maximumDepth, depth); - pending.push(...node.children.map(child => ({ node: child, depth: depth + 1 }))); + pending.push(...node.children.map(child => ({ node: child, depth: depth + 1, underCell: underCell || node.organization.type === 'cell' }))); } assert.ok(maximumDepth >= 3); assert.ok(new Set(Object.values(summary.byOrganization).map(value => value.direct)).size > 1, 'unequal organization membership'); } +test('generated small/medium/large fixtures follow the hierarchy, retaining team -> cell examples', () => { + for (const dataset of [small, medium, fixture] as OrgChartDataset[]) assertContract(dataset); + const byId = new Map(base.organizations.map(org => [org.id, org])); + const cells = base.organizations.filter(org => org.type === 'cell'); + assert.equal(cells.length, companies.length * 2); + for (const cell of cells) assert.equal(byId.get(cell.parentId!)!.type, 'team'); +}); + +test('hierarchy regression check rejects a team below an indirect cell ancestor', () => { + const invalid = structuredClone(base); + const office = invalid.organizations.find(org => org.id === 'hanmac-office-sibling')!; + office.parentId = 'hanmac-cell-0'; + invalid.organizations.find(org => org.id === 'hanmac-team-sibling')!.parentId = office.id; + assert.throws(() => assertContract(invalid), /must not descend from a cell/); +}); + for (const employeeCount of sizes) { test(`total ${employeeCount} contains exactly that many unique employees across all seven preset companies`, () => { const dataset = createOrgChartExampleDataset(base, { scope: 'total', employeeCount }); diff --git a/packages/org-chart/src/styles/employee-card.css b/packages/org-chart/src/styles/employee-card.css index 2d5a54f..2b6b72a 100644 --- a/packages/org-chart/src/styles/employee-card.css +++ b/packages/org-chart/src/styles/employee-card.css @@ -248,7 +248,7 @@ .hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-name { color: var(--hm-orgchart-card-color, #071D3A); font-size: var(--hm-orgchart-person-name-size); - font-weight: 600; + font-weight: 700; line-height: var(--hm-orgchart-person-name-line-height); letter-spacing: var(--hm-orgchart-person-name-letter-spacing); overflow: hidden; @@ -264,7 +264,7 @@ .hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] .hm-orgchart-person-attributes { left: 4px; width: 36px; } .hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-job-title { max-width: 100%; padding: 0 2px; font-size: 8px; line-height: 12px; letter-spacing: -0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"] .hm-orgchart-job-title { font-size: 9px; font-weight: 700; } -.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]):is([data-card-asset-size="employee"], [data-card-layout="compact"]) .hm-orgchart-job-title { font-size: 8.5px; font-weight: 600; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]):is([data-card-asset-size="employee"], [data-card-layout="compact"]) .hm-orgchart-job-title { font-size: 8.5px; font-weight: 500; } /* Workdesk source: index.css .rank-position/.role-rank and .bg-*. Preserve the approved SVG geometry and position/name typography. The photo slot uses the existing reserved width because the approved cards are smaller @@ -326,7 +326,7 @@ .hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]):is([data-card-asset-size="employee"], [data-card-layout="compact"]) .hm-orgchart-job-title { padding: 0 2px; font-size: 8.5px; - font-weight: 600; + font-weight: 500; } /* Status is retained for assistive reading; the specified two-line card composition has no third status row. Supplementary status cards are intact. */ diff --git a/packages/org-chart/src/styles/org-chart-readonly.css b/packages/org-chart/src/styles/org-chart-readonly.css index e3dcea5..43e81e1 100644 --- a/packages/org-chart/src/styles/org-chart-readonly.css +++ b/packages/org-chart/src/styles/org-chart-readonly.css @@ -132,7 +132,7 @@ } /* Workdesk .hw/.hr labels share compact typography; .sw/.ux retain 20px/700. */ .hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"][data-organization-id^="business-planning-"] .hm-orgchart-organization-name, -.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"][data-organization-id^="talent-growth-"] .hm-orgchart-organization-name { font-size: 18px; font-weight: 600; line-height: 16.9px; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"][data-organization-id^="talent-growth-"] .hm-orgchart-organization-name { font-size: 18px; font-weight: 700; line-height: 16.9px; } /* Stacked cells: the vertical spine follows the 16px node gap. */ .hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes > .hierarchy::before { top: -16px; height: 16px; } .hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-leader { margin: 8px 0 0; } @@ -182,7 +182,7 @@ gap: 8px; } .hm-orgchart-hub-icon { width: 20px; height: 20px; flex: 0 0 20px; stroke: #f8fcff; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.38)); } -.hm-orgchart-hub-label { position: relative; font-size: 21px; font-weight: 750; line-height: 24px; letter-spacing: normal; background: linear-gradient(180deg, #dbeeff, #f8fcff 15.38%, #fff 48.08%, #f4faff 79.33%, #dbeeff); background-clip: text; -webkit-text-fill-color: transparent; filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.5)); } +.hm-orgchart-hub-label { position: relative; font-size: 21px; font-weight: 700; line-height: 24px; letter-spacing: normal; background: linear-gradient(180deg, #dbeeff, #f8fcff 15.38%, #fff 48.08%, #f4faff 79.33%, #dbeeff); background-clip: text; -webkit-text-fill-color: transparent; filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.5)); } .hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-axis::before, .hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-hub::after, .hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-forest::before, diff --git a/packages/ui/src/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx index faf1787..35cb468 100644 --- a/packages/ui/src/components/ui/card.tsx +++ b/packages/ui/src/components/ui/card.tsx @@ -44,7 +44,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
meta gap 20px, title heading-md 20/28/600 (CANONICAL_OVERRIDE of Source 19px) */ + /* ui_viz: content-header.md §7.1, §9 — content profile: title <-> meta gap 20px, title 20/28/700 (HM three-weight policy, 2026-10-14; Source geometry retained) */ --hm-card-header-gap: 20px; --hm-card-title-font-size: 20px; --hm-card-title-line-height: 28px; - --hm-card-title-font-weight: 600; + --hm-card-title-font-weight: var(--hm-font-weight-bold); --hm-card-title-color: var(--hm-sem-fg-primary); } diff --git a/scripts/font-weight-policy.test.mjs b/scripts/font-weight-policy.test.mjs new file mode 100644 index 0000000..f9b2156 --- /dev/null +++ b/scripts/font-weight-policy.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +const allowed = new Set(['400', '500', '700']); +async function sources(directory) { + const output = []; + for (const item of await readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, item.name); + if (item.isDirectory()) output.push(...await sources(file)); + else if (/\.(css|tsx?|html|svg)$/.test(item.name) && !/\.test\./.test(item.name)) output.push(file); + } + return output; +} +const files = (await Promise.all(['packages/ui/src', 'packages/org-chart/src', 'packages/org-chart-react/src', 'packages/center-graphic/src', 'src', '.storybook'].map(sources))).flat(); +const contents = await Promise.all(files.map(async file => ({ file, text: await readFile(file, 'utf8') }))); + +test('HM source uses only Regular, Medium and Bold; font-face ranges are asset metadata', () => { + const issues = []; + for (const { file, text } of contents) { + const css = text.replace(/@font-face\s*\{[^}]*\}/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); + for (const match of css.matchAll(/(?:font-weight\s*:\s*|fontWeight\s*:\s*)(\d+(?:\.\d+)?)/g)) { + if (!allowed.has(match[1])) issues.push(`${file}: ${match[0]}`); + } + for (const match of css.matchAll(/\bfont-(?:thin|extralight|light|semibold|extrabold|black)\b|\bfont-\[(\d+)\]/g)) { + if (!match[1] || !allowed.has(match[1])) issues.push(`${file}: ${match[0]}`); + } + } + assert.deepEqual(issues, []); +}); + +test('Every default HM weight token resolves to a supported value without dangling references', () => { + const tokens = new Map(); + for (const { file, text } of contents.filter(item => item.file.startsWith(`packages${path.sep}ui${path.sep}src`) && item.file.endsWith('.css'))) { + for (const [, name, value] of text.matchAll(/(--hm-[\w-]+)\s*:\s*([^;{}]+);/g)) tokens.set(name, value.trim()); + } + function resolve(name, chain = []) { + assert.ok(!chain.includes(name), `Circular token: ${[...chain, name].join(' -> ')}`); + assert.ok(tokens.has(name), `Missing token: ${name}`); + const value = tokens.get(name), reference = value.match(/^var\((--[\w-]+)\)$/); + return reference ? resolve(reference[1], [...chain, name]) : value; + } + for (const name of tokens.keys()) if (/weight/.test(name)) assert.ok(allowed.has(resolve(name)), `${name} = ${resolve(name)}`); + assert.equal(resolve('--hm-input-font-weight'), '400'); + assert.equal(resolve('--hm-font-weight-medium'), '500'); + assert.equal(resolve('--hm-font-weight-bold'), '700'); + assert.equal(resolve('--hm-card-title-font-weight'), '700'); + for (const { file, text } of contents) for (const [, name] of text.matchAll(/var\((--hm-(?!orgchart)[\w-]*weight[\w-]*)/g)) assert.ok(tokens.has(name), `Missing weight reference in ${file}: ${name}`); +}); + +test('Theme verification baseline agrees with declared default weight tokens', async () => { + const baseline = JSON.parse(await readFile('src/theme/verification-baseline.json', 'utf8')); + const declarations = new Map(); + for (const { file, text } of contents.filter(item => item.file.startsWith(`packages${path.sep}ui${path.sep}src${path.sep}theme${path.sep}`) && item.file.endsWith('.css'))) { + for (const [, name, value] of text.matchAll(/(--hm-[\w-]*weight[\w-]*)\s*:\s*([^;{}]+);/g)) declarations.set(name, { file, value: value.trim() }); + } + // The verification fixture captures a subset, not every later-added token. + for (const name of Object.keys(baseline).filter(name => /weight/.test(name))) assert.equal(declarations.get(name)?.value, baseline[name], name); +}); diff --git a/src/center/center-stories.css b/src/center/center-stories.css index b5fa2a4..e5dcbf6 100644 --- a/src/center/center-stories.css +++ b/src/center/center-stories.css @@ -26,7 +26,7 @@ .sb-center-note { max-width: 880px; margin: 0; color: var(--hm-sem-fg-secondary); font-size: var(--hm-button-font-size-sm); line-height: var(--hm-button-line-height-sm); overflow-wrap: anywhere; } .sb-center-status { width: 100%; border-collapse: collapse; font-size: var(--hm-button-font-size-sm); line-height: var(--hm-button-line-height-sm); } .sb-center-status th, .sb-center-status td { border: 1px solid #e0dace; padding: 6px 10px; text-align: left; vertical-align: top; } -.sb-center-status th { background: #f4f1ea; font-weight: 600; } +.sb-center-status th { background: #f4f1ea; font-weight: 700; } .sb-center-status code { font-size: inherit; } .sb-center-scroll { min-width: 0; max-width: 100%; overflow-x: auto; } .sb-center-information { min-width: 0; width: 100%; } diff --git a/src/components/button.stories.tsx b/src/components/button.stories.tsx index bc85dea..f9f06cf 100644 --- a/src/components/button.stories.tsx +++ b/src/components/button.stories.tsx @@ -68,7 +68,7 @@ type Story = StoryObj const row = "flex flex-wrap items-center gap-4" const stack = "grid gap-6" -const heading = "text-base font-semibold" +const heading = "text-base font-bold" const note = "text-sm text-muted-foreground" function RolesBoard() { diff --git a/src/components/carousel.stories.tsx b/src/components/carousel.stories.tsx index f4583d8..9bf46e2 100644 --- a/src/components/carousel.stories.tsx +++ b/src/components/carousel.stories.tsx @@ -19,7 +19,7 @@ const svg = (n: number) => function SlideCard({ n }: { n: number }) { return ( - {n + 1} + {n + 1} ) } diff --git a/src/components/select.stories.tsx b/src/components/select.stories.tsx index 847f734..e91e963 100644 --- a/src/components/select.stories.tsx +++ b/src/components/select.stories.tsx @@ -108,11 +108,11 @@ function DisabledStates() { return (
-

Selected Value

+

Selected Value

-

Placeholder

+

Placeholder

@@ -274,7 +274,7 @@ export const OpenTopLong: Story = { function ListSizeColumn({ title, listSize, width }: { title: string; listSize: SelectSize; width: number }) { return (
-

{title}

+

{title}

diff --git a/src/foundation/typography.stories.tsx b/src/foundation/typography.stories.tsx index d38d574..31a6053 100644 --- a/src/foundation/typography.stories.tsx +++ b/src/foundation/typography.stories.tsx @@ -2,12 +2,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite" import { typographyProperties, typographyReferences } from "./reference-data" import { ReferencePage, ReferenceSection, Specimen, useResolvedProperties } from "./reference" -const weights = ["--hm-input-font-weight", "--hm-font-weight-medium", "--hm-card-title-font-weight", "--hm-font-weight-bold"] +const weights = [ + { property: "--hm-input-font-weight", label: "Regular" }, + { property: "--hm-font-weight-medium", label: "Medium" }, + { property: "--hm-font-weight-bold", label: "Bold" }, +] function TypographyReferencePage() { const values = useResolvedProperties(typographyProperties) return

Noto Sans KR → 맑은 고딕 → 시스템 sans-serif

구현 참고 · 전체 CSS Font Stack--hm-font-family-base

{values["--hm-font-family-base"]}

/fonts/NotoSansKR-variable.ttf · font-weight 100–900 asset

-
{weights.map(property =>
{property}

{values[property]}

한맥 프로젝트
Hanmac Project
0123456789

)}
+
{weights.map(({ property, label }) =>
{property}

{values[property]} — {label}

한맥 프로젝트
Hanmac Project
0123456789

)}
{typographyReferences.map(item =>

{item.label}

packages/ui/src/theme/{item.source}

Size
{item.size} · {values[item.size]}
Line Height
{item.line} · {values[item.line]}
Weight
{item.weight ? <>{item.weight} · {values[item.weight]} : "normal · 컴포넌트에서 별도 weight 지정 없음"}
)}
} diff --git a/src/hm-theme.css b/src/hm-theme.css index ecbd6d3..448359a 100644 --- a/src/hm-theme.css +++ b/src/hm-theme.css @@ -19,13 +19,13 @@ body { margin: 0; } .hm-app { min-width: 320px; } .hm-container { width: min(1180px, calc(100% - 48px)); margin-inline: auto; } .hm-topbar { background: var(--hm-sem-bg-brand); color: #fff; border-bottom: 1px solid #31594c; padding: 15px 0; } -.hm-mark { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid #80b6a3; border-radius: 4px; background: #174b3a; font-size: 16px; font-weight: 800; letter-spacing: -.08em; } +.hm-mark { display: grid; place-items: center; width: 42px; height: 42px; border: 1px solid #80b6a3; border-radius: 4px; background: #174b3a; font-size: 16px; font-weight: 700; letter-spacing: -.08em; } .hm-brand-name { margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -.02em; } .hm-brand-caption { margin: 2px 0 0; color: #bddfd4; font-size: 11px; } .hm-topbar-status { display: inline-flex; align-items: center; gap: 7px; color: #c6d8d1; font-size: 12px; white-space: nowrap; } .hm-topbar-status span { width: 7px; height: 7px; border-radius: 50%; background: #67d6ac; box-shadow: 0 0 0 4px #67d6ac22; } .hm-breadcrumb { color: #827b70; } -.hm-breadcrumb strong { color: var(--hm-green); font-weight: 600; } +.hm-breadcrumb strong { color: var(--hm-green); font-weight: 500; } .hm-hero { position: relative; overflow: hidden; min-height: 330px; border: 1px solid #254d40; border-radius: 4px; background: var(--hm-sem-bg-brand); color: #fff; } .hm-hero::after { content: ""; position: absolute; width: 590px; height: 590px; right: -240px; top: -175px; border: 1px solid #ffffff24; border-radius: 50%; box-shadow: 0 0 0 72px #ffffff09,0 0 0 145px #ffffff06; } .hm-eyebrow { display: inline-flex; align-items: center; gap: 7px; color: #c3e6d7; font-size: 11px; font-weight: 700; letter-spacing: .13em; } @@ -41,7 +41,7 @@ body { margin: 0; } .hm-hero-art-pill span { width: 7px; height: 7px; border-radius: 50%; background: #c8e4d5; } .hm-hero-art-pill span:nth-child(2) { background: #74bd98; } .hm-hero-art-pill span:nth-child(3) { background: #477b5a; } -.hm-section-kicker { margin: 0 0 6px; color: #6b715f; font-size: 11px; font-weight: 800; letter-spacing: .16em; } +.hm-section-kicker { margin: 0 0 6px; color: #6b715f; font-size: 11px; font-weight: 700; letter-spacing: .16em; } .hm-section-heading h2 { margin: 0; color: var(--hm-ink); font-size: 26px; font-weight: 700; letter-spacing: -.04em; } .hm-section-heading p:not(.hm-section-kicker) { margin: 7px 0 0; color: var(--hm-muted); font-size: 13px; } .hm-count { border: 1px solid #d4cfc2; border-radius: 20px; padding: 6px 10px; color: #6e6759; font-size: 11px; white-space: nowrap; } @@ -57,10 +57,10 @@ body { margin: 0; } .hm-swatch { min-width: 0; } .hm-swatch-color { display: block; height: 65px; border: 1px solid #d5cfc3; border-radius: 4px; } .hm-swatch-color--dark { border-color: #1e3b31; } -.hm-swatch-name { display: block; margin-top: 8px; color: var(--hm-ink); font-size: 11px; font-weight: 600; overflow-wrap: anywhere; } +.hm-swatch-name { display: block; margin-top: 8px; color: var(--hm-ink); font-size: 11px; font-weight: 500; overflow-wrap: anywhere; } .hm-swatch-value { color: #8b857a; font-size: 10px; } .hm-field { display: grid; gap: 8px; } -.hm-field label { font-size: 12px; font-weight: 600; } +.hm-field label { font-size: 12px; font-weight: 500; } .hm-form-footer { border-top: 1px solid #eee9e0; padding-top: 18px; } .hm-form-footer p { display: inline-flex; align-items: center; gap: 6px; margin: 0; color: var(--hm-muted); font-size: 11px; } .hm-form-footer p svg { color: #1a7551; } @@ -70,7 +70,7 @@ body { margin: 0; } .hm-inner-card p { max-width: 340px; margin: 0 0 19px; color: var(--hm-muted); font-size: 12px; line-height: 1.7; word-break: keep-all; } .hm-dialog-summary { display: grid; grid-template-columns: 110px 1fr; gap: 10px; margin: 8px 0 14px; border-top: 1px solid #dedbd2; border-bottom: 1px solid #dedbd2; padding: 16px 0; font-size: 13px; } .hm-dialog-summary span { color: var(--hm-muted); } -.hm-dialog-summary strong { font-weight: 600; } +.hm-dialog-summary strong { font-weight: 500; } .hm-footer { border-top: 1px solid #d7d1c5; padding-top: 19px; color: #7b7468; } @media (max-width: 700px) { diff --git a/src/theme/designer-theme.css b/src/theme/designer-theme.css index 70423b0..59b0c4b 100644 --- a/src/theme/designer-theme.css +++ b/src/theme/designer-theme.css @@ -6,7 +6,7 @@ .hm-designer-layout { display: grid; grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); gap: 24px; } .hm-designer-editor, .hm-designer-preview { min-width: 0; padding: 20px; background: var(--hm-sem-bg-surface); border: 1px solid var(--hm-sem-stroke-input-default); border-radius: 4px; } .hm-designer-field { padding: 14px 0; border-bottom: 1px solid var(--hm-sem-stroke-input-default); } -.hm-designer-field label { display: block; font-weight: 600; } +.hm-designer-field label { display: block; font-weight: 500; } .hm-designer-field small { display: block; font-size: 12px; font-weight: 400; margin: 3px 0 8px; } .hm-designer-inputs { display: flex; gap: 8px; } .hm-designer-inputs input[type=color] { width: 42px; height: 34px; padding: 2px; cursor: pointer; } diff --git a/src/theme/verification-baseline.json b/src/theme/verification-baseline.json index 7844bb3..c9cf137 100644 --- a/src/theme/verification-baseline.json +++ b/src/theme/verification-baseline.json @@ -174,7 +174,7 @@ "--hm-card-header-gap": "20px", "--hm-card-title-font-size": "20px", "--hm-card-title-line-height": "28px", - "--hm-card-title-font-weight": "600", + "--hm-card-title-font-weight": "var(--hm-font-weight-bold)", "--hm-card-title-color": "var(--hm-sem-fg-primary)", "--hm-carousel-gap": "16px", "--hm-carousel-control-size": "32px", diff --git a/src/work-dashboard/work-dashboard.css b/src/work-dashboard/work-dashboard.css index a64dc3f..91b6605 100644 --- a/src/work-dashboard/work-dashboard.css +++ b/src/work-dashboard/work-dashboard.css @@ -6,7 +6,7 @@ .work-status-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .work-status-filters > span { margin-right: 8px; } #work-results, #project-overview, #work-deadlines { scroll-margin-top: var(--hm-card-padding); } -.work-kicker { margin: 0; color: var(--hm-sem-fg-secondary); font-size: 12px; font-weight: 600; letter-spacing: .12em; } +.work-kicker { margin: 0; color: var(--hm-sem-fg-secondary); font-size: 12px; font-weight: 500; letter-spacing: .12em; } .work-date { display: flex; align-items: center; gap: 8px; margin-right: 8px; color: var(--hm-sem-fg-secondary); font-size: 13px; white-space: nowrap; } .work-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--hm-card-padding); } .work-summary-icon { color: var(--hm-sem-action-primary); } @@ -28,7 +28,7 @@ .work-issue-item { display: grid; gap: 8px; } .work-deadline-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; } .work-deadline-item { display: flex; align-items: center; gap: 16px; } -.work-deadline-date { display: grid; justify-items: center; gap: 4px; flex: none; color: var(--hm-sem-action-primary); font-size: 14px; font-weight: 600; } +.work-deadline-date { display: grid; justify-items: center; gap: 4px; flex: none; color: var(--hm-sem-action-primary); font-size: 14px; font-weight: 500; } .work-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding-bottom: 8px; color: var(--hm-sem-fg-secondary); font-size: 12px; } .work-editor { width: min(560px, calc(100vw - 32px)); } .work-editor-form { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }