Files
HMDesign/docs/patterns/dashboard-layout.md
T

357 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Dashboard (Pattern)
> **SINGLE NAVIGATION MODE / 2026-10-08 Sidebar 운영 정책.** DASHBOARD ONLY 운영 화면은 Sidebar를 사용하지 않는다. 전용 Header(48px)·업무 상태 필터와 Content를 사용하며 Header는 GNB가 아니다. 독립 GNB / LNB / Dashboard 모드는 함께 사용하지 않는다. [공통 정책](../guidelines/navigation-single-mode.md)이 이전 조합·Sidebar 허용 기록보다 우선한다.
## 1. 현재 계약
Dashboard 운영 화면은 전용 Header/Menu·업무 상태 필터와 Content로 구성하며 모든 Sidebar 사용을 금지한다. 외부 GNB와 일반 LNB를 결합하지 않는다. 운영 Reference는 Sidebar 없는 대표 `Patterns/Layout/Dashboard → Dashboard` Story다.
| 공개 API | 역할 |
|---|---|
| `DashboardLayout` | 바깥 scroll root + 내부 frame. 운영 화면은 `header`, `children`을 사용하고 `sidebar`를 전달하지 않음 |
| `DashboardHeader` | Dashboard 내부 Header. 기본 높이 48px |
| `DashboardSidebar` | 기존 호환성·검증 전용 공개 API. 신규 Dashboard 운영 화면 사용 금지. 기존 폭 272px·메뉴 세로 스크롤 기능은 유지 |
| `DashboardMain` | `<main>`, 콘텐츠 및 세로 스크롤 |
| `DashboardContent` | 콘텐츠 stack, gap 24px |
`DashboardSidebar` 공개 Export와 선택적 `sidebar` slot은 기존 소비자 호환성·검증을 위해 유지한다. API 존재가 운영 허용을 뜻하지 않으며 신규 운영 화면에는 사용하지 않는다. GNB/LNB나 Sidebar를 전달하던 운영 화면은 적합한 단일 Navigation Mode의 운영 구성으로 마이그레이션한다. slot은 임의 ReactNode를 받으며 런타임 자동 차단은 없다. 운영 사용 금지와 기능 제거는 별도 작업이며, API 제거·런타임 차단은 소비처 영향 조사와 사용자 결정 후 진행한다.
```tsx
<DashboardLayout
header={<DashboardHeader>Dashboard Header / Menu</DashboardHeader>}
>
<DashboardMain>
<PageHeader>…</PageHeader>
<DashboardContent>…</DashboardContent>
</DashboardMain>
</DashboardLayout>
```
## 2. 최소 폭·스크롤·스타일
- 운영 최소 폭 = Sidebar 없는 960px(`--hm-dashboard-min-width`).
- Sidebar 포함 1232px(960 + 기존 Sidebar 폭 272px)은 호환성·검증 구성의 값이며 현재 운영 기준이 아니다. 해당 구현·CSS는 호환성을 위해 유지한다.
- 최소 폭 이하에서는 바깥 `dashboard-scroll` 안에서 전용 Header/Content 전체를 가로 스크롤한다. 모바일에서도 Sidebar를 추가하지 않는다.
- Main이 세로 스크롤을 담당하고 바깥 frame 높이는 `--hm-dashboard-height`(기본 `100dvh`)다. `className`, `style`, DOM props는 바깥 root에 전달한다.
- Root 기본 접근성은 `role="region"`, `aria-label="Dashboard"`, `tabIndex={0}`. `data-navigation-mode="DASHBOARD"`는 해당 체계를 식별한다.
- Main padding 34px, content gap 24px. Header 콘텐츠 radius 12px와 관련 shadow 유지. 기존 Sidebar 콘텐츠 radius 18px·shadow는 호환성·검증용 구현에만 남는다.
- Dashboard는 `--hm-dashboard-*`와 공통 Semantic만 사용한다. GNB 높이·join, LNB 폭·gradient, 1024px 모바일 숨김 override에 의존하지 않는다.
- Filter Bar는 Dashboard 최소 폭 frame 안에서 한 줄과 기존 control 폭을 유지한다. 넓은 개별 콘텐츠는 Main 또는 카드 내부에서 스크롤한다.
## 3. Storybook
대표 `Patterns/Layout/Dashboard → Dashboard`는 Compact Header 48px + 상태 필터 + 콘텐츠다. Compact 상태 바 geometry(높이 40px, 중심 50% + 18px, 28px control, 36px padding, radius 16px 및 오목한 날개), 전체/진행/보류/완료 count 색상은 기존 기준을 유지한다. count 진행 `#00c896` / 보류 `#ff9500`는 2026-10-07 사용자 결정에 따른 Story 전용 값이다.
### Dashboard 업무 상태 공통 계약
업무 상태 계약은 `packages/ui/src/patterns/dashboard-status/contract.ts`가 기준이다. `active | hold | done`은 실제 상태이고, `all | active | hold | done | issue`는 필터 ID다. `issue`는 상태를 대체하지 않는 독립 Boolean flag이며 `hold + issue`처럼 동시에 표현할 수 있다. `done`의 표시명은 `완료`를 유지한다.
계약은 label, tone, 접근성 label, count 계산(`countDashboardFilter`), 필터 매칭(`matchesDashboardFilter`)을 제공한다. React adapter는 기존 `Badge`, `Button`, `FilterBar`, `Progress`를 사용하고, 다른 Runtime은 동일한 `data-status`, `data-issue="true"`, `data-filter`, `aria-pressed` vocabulary를 사용한다. 선택 상태는 `aria-pressed="true"`가 우선하며 hover/focus/disabled는 대상 Runtime의 interaction state로 동일하게 매핑한다. 상태 색상 원본은 `packages/ui/src/patterns/dashboard-status/dashboard-status.css`이며 `@hm-design/ui/dashboard-status.css`로 공개한다. 기존 `src/patterns/dashboard-status-contract.css`는 원본을 import하는 호환 경로로 유지한다. 이 변수는 기존 Story 색상을 보존하는 compatibility mapping이며 HM Theme token은 변경하지 않는다. Static HTML 기준 샘플은 `docs/patterns/dashboard-status-static.html`이다.
패키지 소비자는 앱 CSS entry에서 Theme 다음에 상태 CSS를 명시적으로 로드한다. React API import만으로 CSS가 자동 적용되지는 않는다.
```css
@import "tailwindcss";
@import "@hm-design/ui/tailwind.css";
@import "@hm-design/ui/theme.css";
@import "@hm-design/ui/dashboard-status.css";
```
상태 CSS는 변수 정의만 제공한다. 기존 `Badge variant="outline"`에는 화면의 wrapper에서 아래 mapping을 적용한다. 완료의 흰색 ink가 유지되므로 기존 어두운 Badge 배경도 함께 유지한다. `issue` Badge는 실제 상태 Badge와 별도로 렌더링한다.
```css
.dashboard-status [data-status="active"] { --dashboard-status-color: var(--hm-dashboard-status-active); }
.dashboard-status [data-status="hold"] { --dashboard-status-color: var(--hm-dashboard-status-hold); }
.dashboard-status [data-status="done"] { --dashboard-status-color: var(--hm-dashboard-status-done); }
.dashboard-status [data-status="issue"][data-issue="true"] { --dashboard-status-color: var(--hm-dashboard-status-issue); }
.dashboard-status [data-slot="badge"][data-status] {
--hm-badge-outline-text: var(--dashboard-status-color);
--hm-badge-outline-background: color-mix(in srgb, var(--hm-sem-palette-green-dark-2) 90%, transparent);
--hm-badge-outline-border-color: color-mix(in srgb, var(--hm-sem-palette-black) 50%, transparent);
}
```
HTML adapter는 같은 상태 변수를 자신의 `color` 등에 연결한다. package dist를 정적 자산으로 배치하면 `theme.css` 다음에 `dashboard-status.css`를 `<link>`로 로드한다. 저장소의 기존 Static HTML 예제는 호환 CSS 경로를 그대로 사용한다. React와 HTML 모두 `done`은 `--hm-dashboard-status-done`을 참조하며, 이 alias는 `:root`와 `[data-hm-theme]`에서 각 scope의 white token으로 재계산한다. active/hold/issue 색상과 Runtime Theme의 73개 계약은 유지한다.
#### React 소비 예제: 데이터 → 상태 변수 → Badge
`active`(진행) → `--hm-dashboard-status-active`, `hold`(보류) → `--hm-dashboard-status-hold`, `done`(완료) → `--hm-dashboard-status-done`이다. 독립 `issue === true`는 `--hm-dashboard-status-issue`로 별도 Badge를 표시한다. `tone`은 의미 분류이며 Badge variant나 CSS를 자동 선택하지 않는다. 위 CSS mapping과 import 순서를 앱 CSS entry에 적용한 다음 아래 공개 API를 사용한다.
```tsx
import {
Badge, dashboardFilterDefinitions, matchesDashboardFilter,
type DashboardFilterId, type DashboardStatusRecord,
} from "@hm-design/ui"
type Work = DashboardStatusRecord & { id: string }
const works: Work[] = [
{ id: "T-1", status: "active" },
{ id: "T-2", status: "hold", issue: true },
{ id: "T-3", status: "done" },
]
export function WorkBadges({ filter = "all" }: { filter?: DashboardFilterId }) {
const visible = works.filter(work => matchesDashboardFilter(work, filter))
return <div className="dashboard-status">
{visible.map(work => <div key={work.id}>
<Badge variant="outline" data-status={work.status}>
{dashboardFilterDefinitions[work.status].label}
</Badge>
{work.issue === true && <Badge variant="outline" data-status="issue" data-issue="true">
{dashboardFilterDefinitions.issue.label}
</Badge>}
</div>)}
</div>
}
```
`filter="issue"`에서도 T-2의 상태는 `hold`로 유지되고 보류 Badge와 이슈 Badge가 함께 표시된다. `issue`를 `DashboardStatusId`에 추가하거나 `destructive` 등 다른 Badge variant에 임의로 연결하지 않는다.
Reference: [React 代表 Story](../../src/patterns/dashboard-layout.stories.tsx)の `statusBadge` と [Story CSS](../../src/patterns/dashboard-layout.stories.css)の Badge mapping、[公開状態 CSS 原本](../../packages/ui/src/patterns/dashboard-status/dashboard-status.css)、[Static HTML](dashboard-status-static.html)。上の例はこれらの状態・色 mappingを公開 APIで消費する例であり、Storyモジュールは importしない。コード・Propsはソースと照合済みで、この文書改善作業では例の実行・画面検証はしていない。
Sidebar 검증 Story `WithSidebar`, `SidebarOnly`, `ThirdLevel`, `LongContent`, `Framed`는 기존 기능·상태 데이터를 보존하는 **VERIFICATION ONLY — 운영 사용 금지** 화면이다. `verification` 태그로 기본 Story 메뉴에서 제외하며 이름과 화면에 운영 금지를 표시한다. 신규 운영 화면 Reference로 사용하지 않는다. `Loading`, `Empty`도 verification 전용이며 Sidebar 없는 Compact 구성을 검사한다. 대표 Story ID `patterns-layout-dashboard-layout--all`과 기존 검증 Story ID는 유지한다. 이전 GNB + LNB / GNB Only / LNB Only Story 구성은 §5의 역사 기록이다.
## 4. 단일 모드 통합 검증 (2026-10-07, headless Edge)
> **HISTORICAL RECORD:** 아래 결과는 2026-10-07 당시 코드의 검증 기록이다. Sidebar 포함 1232px 사례는 현재 운영 허용이나 이번 정책 수정의 검증 결과를 뜻하지 않는다. 현재 운영 계약은 §1–3의 Sidebar 없는 960px 구성이다.
- Navigation/Theme Story × viewport 총 48개 케이스 통과. 1280, 1232, 1024, 1023, 960, 959, 390, 320px에서 주요 모드를 확인했다.
- Dashboard에 외부 GNB/LNB 없음, Compact 960px / Sidebar 포함 1232px 최소 폭과 내부 가로 스크롤 유지, 문서 가로 넘침 0px.
- LNB 1024/1023 전환, Sheet Escape·포커스 복귀·preventDefault 링크 선택·Desktop/Mobile 선택 상태 공유 확인. Dashboard 그룹 Enter/Space, 현재 링크 및 hover 유지 확인.
- Theme Preview는 GNB/LNB 중 하나만 렌더링하고 Dashboard Theme 콘텐츠는 외부 Navigation을 제외한다. Runtime Theme 계약 검증 136개 통과, 브라우저 runtime error 0.
- 타입 검사, UI package build, Storybook static build, Production 앱 build, `git diff --check` 통과. 기존 문서 예시 `bg-[var(...)]` CSS 경고 및 Storybook chunk 크기 경고는 남아 있다.
## 5. 이전 구현·검증 이력 (HISTORICAL RECORD)
아래 내용은 정책 전환 이전 기록을 보존한 것이다. **현재 구현·운영·AI 생성의 근거로 사용하지 않는다.** GNB/LNB 혼합, 모바일 체계 전환, 교차 CSS 변수 및 이전 Story 구성은 위 §1–3과 공통 SINGLE NAVIGATION MODE 정책으로 대체되었다. 아래 테스트 수치는 이전 코드의 결과이며 이번 변경의 검증 결과가 아니다.
<details>
<summary>2026-10-07 단일 모드 전환 이전 기록</summary>
### Dashboard Layout (Pattern)
현재 HM 구현의 기준 문서. 공통 원칙은 `docs/guidelines/프로젝트-지침.md`(§6.10, §6.11)를 따른다.
```text
Layout visual reference: 외부 ui_viz Reference (ZIP 미포함) (dashboard-composition.md §7·§8·§19, Variant A Sidebar / Variant B Top-header)
Implementation: current @hm-design/ui patterns / primitives
```
#### 1. 상태
| 항목 | 내용 |
|---|---|
| 구현 상태 | 구현 완료 (2026-10-03) |
| 기반 | GNB, LNB, Page Header, Card, Badge, Progress, Filter Bar, Data Table 조합 (새 dependency 없음) |
| 대표 Story | `Patterns/Layout/Dashboard Layout` → `Dashboard Layout` (verification: Third Level, GNB Only, LNB Only, Long Content, Loading, Empty, Framed) |
| 함께 만든 Pattern | `Page Header` (`page-header.md`) |
| 보류 | §12 |
#### 2. 목적 / 범위
GNB / LNB / Page Header / Main을 실제 화면으로 조립해 Layout contract를 확정한다. 새 UI를 만들지 않는다. 메뉴·페이지 내용은 소비자가 slot과 children으로 넣고, Dashboard는 메뉴 schema를 모른다. 소유하는 것: 화면 프레임(높이), main 스크롤, 콘텐츠 면(배경·padding·둥근 모서리·그림자), 콘텐츠 세로 리듬(gap).
#### 3. ui-viz visual reference
ui-viz에서 확인한 구조(실제 파일: `compositions/dashboard-variant-specimens.{jsx,css}`, `top-header-specimen.{jsx,css}`, `storybook/{LNB,GNB,Layout}.stories.jsx`, `layout-composition.jsx`, `dashboard-composition.md`):
| 영역 | ui-viz 구조 | 현재 HM 대응 | 이번 구현 |
|---|---|---|---|
| 조립 | Variant A(LNB 272 + 콘텐츠), Variant B(GNB 70 + 콘텐츠), Variant C(Compact 48). **GNB+LNB 동시 사용 예 없음** | `DashboardLayout` | slot 방식. 둘 다 / 하나만 |
| GNB | 70px green, 콘텐츠와 12px 접점 | `GNB` | slot `header` |
| LNB | 272px green, 콘텐츠와 18px 접점 | `LNB` | slot `sidebar` |
| Content surface | `#F5F0E5`, padding 34, 접점 쪽 radius(LNB 18 / GNB 12), 그림자 `-8px 0 18px`(LNB) · `0 -8px 18px`(GNB) rgba(7,30,24,.18), 별도 border 없음 | 신규 | `DashboardMain` |
| 스크롤 | "메뉴/헤더는 고정, 콘텐츠 영역에서만 스크롤"(Confirmed). `overflow-y:auto`, `overscroll-behavior: contain` | 신규 | `DashboardMain`이 스크롤 소유 |
| Page heading | breadcrumb 12px(정적 `p`, 컴포넌트 아님) → 제목 28 → 설명, space-between·align end·gap 24, **하단 20px** | Breadcrumb + 신규 Pattern | `PageHeader` |
| 간격 | 외곽 34, 카드 사이 24, 카드 padding 24, 카드 제목→본문 24, 조회→결과 16 | Card, `DashboardContent` | 24 gap 토큰, 나머지는 Card/Filter Bar 값 |
| 조회 | 결과 카드 안 검색(라벨 좌측, 최대 360) + 표 | Filter Bar + Data Table | Card 안에서 조합 (gap 16) |
| KPI | Dashboard에 Panel/Card 4종(KPI 전용 아님) | Card + Badge + Progress | Story 조합만 |
#### 4. GNB / LNB 조립 정책
| 구성 | 사용 | 근거 |
|---|---|---|
| GNB only (Variant B) | `header` | ui-viz 기준 있음 |
| LNB only (Variant A) | `sidebar` | 모든 viewport에서 Sidebar 유지. 최소 폭 이하에서는 Dashboard 전체 가로 스크롤 |
| GNB + LNB | `header` + `sidebar` | ui-viz에 기준 없음. 사용자 요청으로 조립한 INTENTIONAL 구성(대표 Story) |
##### Brand / Profile ownership (2026-10-06, `lnb.md` §3c)
| 구성 | Desktop Brand | Desktop Profile | Mobile Brand | Mobile Profile |
|---|---|---|---|---|
| GNB + LNB | GNB(`GNBBrand`) | GNB(`GNBUser`), LNBProfile 없음 | GNB Mobile Header | GNB Sheet의 `GNBUser`, 별도 LNB Profile 없음 |
| LNB Only | `LNBBrand` | `LNBProfile` | 동일한 `LNBBrand` | 동일한 `LNBProfile` |
| GNB only | GNB(`GNBBrand`) | GNB(`GNBUser`) | GNB Mobile Header | GNB Profile |
Brand/서비스명과 Profile의 소유권은 소비자가 composition에서 `LNBBrand`와 `LNBProfile`을 넣고 빼서 정한다. LNB Only의 `LNBProfile`은 `DropdownMenuTrigger asChild`로 합성하며 작은 viewport에서도 Sidebar와 함께 유지한다. GNB + LNB에서는 LNBProfile을 렌더링하지 않는다.
GNB+LNB일 때: GNB가 전체 폭 위에 평평하게 놓이고 LNB와 콘텐츠가 그 아래 한 줄. GNB의 12px 접점 띠는 숨기고(`::after`) 콘텐츠는 **왼쪽 모서리만 18px**, 위쪽 오른쪽 모서리는 직각이다. 작은 viewport에서도 이 접점을 유지한다. GNB 자체의 모바일 헤더·Sheet 정책은 그대로 유지한다.
#### 5. 구조 / API
```tsx
<DashboardLayout header={<GNB>…</GNB>} sidebar={<LNB>…</LNB>}>
<DashboardMain>
<PageHeader>…</PageHeader>
<DashboardContent>…Card, Filter Bar, Data Table…</DashboardContent>
</DashboardMain>
</DashboardLayout>
```
- `DashboardLayout`: 바깥 `data-slot="dashboard-scroll"`이 가로 스크롤을 맡고, 안쪽 `data-slot="dashboard-layout"`(flex column)이 최소 폭과 `header`/`sidebar` slot을 유지한다. 기존 `className`, `style`, DOM props는 바깥 scroll root에 전달된다. `data-header`/`data-sidebar`는 안쪽 frame에 있다. DOM 순서는 header → (sidebar → main)이다.
- Scroll root는 기본 `role="region"`, `aria-label="Dashboard"`, `tabIndex={0}`이며 DOM props로 변경할 수 있다. CSS 변수는 root에서 내부 frame으로 상속된다.
- `DashboardMain`: `<main>`, 콘텐츠 면이자 세로 스크롤 컨테이너. 고유 `id`/`aria-label`이 필요하면 소비자가 준다.
- `DashboardContent`: 섹션 세로 stack(gap 24). 섹션 스타일은 입히지 않는다.
- GNB / LNB를 wrapper로 다시 감싸지 않고 그대로 slot에 넣는다.
#### 6. Content Surface (접점 소유권)
GNB / LNB는 콘텐츠 radius를 만들지 않는다. 자기 쪽 "연장 띠"만 둔다(GNB `::after` 12px = `--hm-gnb-join`, LNB `::after` 18px = `--hm-lnb-join`). 콘텐츠 면이 그 위에 positioned로 얹혀 둥근 모서리에서 띠가 보이게 한다(`DashboardMain`이 `position: relative`이고 DOM상 뒤).
| 상태 | radius | shadow |
|---|---|---|
| header | `gnb-join gnb-join 0 0` | `0 -8px 18px rgba(7,30,24,.18)` |
| sidebar (모든 viewport) | `lnb-join 0 0 lnb-join` | `-8px 0 18px rgba(7,30,24,.18)` |
| header + sidebar (모든 viewport) | sidebar와 같음, GNB 띠 숨김 | 같음 |
| (둘 다 없음) | 0 | 없음 |
값은 `--hm-gnb-join`, `--hm-lnb-join`을 읽는다(하드코딩 없음). 콘텐츠 면 뒤 색(`--hm-dashboard-background`)은 LNB 끝 색 `--hm-lnb-background-end`.
#### 7. Main Layout
- padding 34px(`--hm-dashboard-padding`, 모든 viewport), 세로 gap 24px(`--hm-dashboard-gap`), 배경 `--hm-sem-bg-content`.
- `min-width: 0`: Main, `DashboardContent`와 그 직접 자식. 표·Carousel·Combobox가 페이지를 밀지 않는다(검증: 1600px 넓은 내용도 Main `scrollWidth`를 늘리지 않음).
- 스크롤: 바깥 Dashboard frame 높이는 `--hm-dashboard-height`(기본 `100dvh`)이며 가로 scrollbar도 이 높이에 포함된다. Main이 세로 스크롤, 바깥 frame이 Dashboard 전체 가로 스크롤을 맡는다. Main은 `overflow-x:auto`, `overscroll-behavior-y:contain`으로 가로 제스처가 바깥 frame으로 전달된다. LNB는 메뉴 영역만 자체 세로 스크롤한다.
- 임베드 시 높이는 `--hm-dashboard-height`로 바꾼다(Story `Framed`).
#### 8. Page Header
별도 Pattern(`page-header.md`)으로 분리했다. 모든 화면이 반복해서 쓰고(Breadcrumb + h1 + actions), 간격(하단 20)이 ui-viz Layout contract라서다. Breadcrumb는 HM Breadcrumb를 그대로 쓰며 Page Header는 간격만 맡는다.
#### 9. Desktop / Mobile
```yaml
Dashboard Minimum Width Policy: HORIZONTAL_SCROLL
Content minimum width: 960px
With Sidebar: 960px + 272px = 1232px
Below minimum width: KEEP_LAYOUT_AND_SCROLL
Compact / Sidebar mobile hide: NONE
```
- 충분한 폭에서는 기존 배치를 사용한다. 작은 viewport에서는 내부 최소 폭을 유지하고 Dashboard 전용 root에서만 가로 스크롤한다. Sidebar slot은 항상 렌더링된다.
- 960px 근거: KPI 4 × 200px + 간격 3 × 24px + padding 2 × 34px + 세로 scrollbar 여유 20px. Compact 상태 바 실측 약 549px, 브랜드 약 125px, 날개 35px도 이 폭에서 겹치지 않는다.
- Sidebar 구성은 콘텐츠 최소 폭에 LNB 폭을 더한다. Desktop은 기존 `--hm-lnb-width`를 읽고, ≤1023px에서는 Dashboard 내부에만 `--hm-lnb-width: var(--hm-dashboard-sidebar-width, 272px)`와 LNB `display:flex`를 적용한다. 공통 `lnb.css`의 모바일 숨김은 유지된다.
- 기본 콘텐츠 최소 폭은 `--hm-dashboard-min-width`로 조정할 수 있다. 기본값 960px은 현재 Desktop 구성 측정 기준이며 별도 콘텐츠를 넣으면 그 구성을 기준으로 조정한다.
- Compact 메뉴는 숨기거나 wrap하지 않는다. Dashboard 안 Filter Bar도 한 줄과 기존 control 폭을 유지하며 공통 Filter Bar 모바일 정책은 변경하지 않는다. 본문 텍스트는 자연스럽게 줄바꿈한다.
- GNB의 전역 메뉴·모바일 Sheet와 일반 LNB의 공통 responsive 정책, Theme/Color Token, Story ID/hierarchy는 변경하지 않는다.
#### 10. Accessibility / Keyboard
- landmark: `header`(GNB), `nav[aria-label=주 메뉴]`, `aside`(LNB) 안 `nav[aria-label=업무 메뉴]`, `main`, `nav[aria-label=breadcrumb]`. 화면의 `h1`은 Page Header 제목 하나.
- Tab 순서(측정): GNB(브랜드, 메뉴 4, 사용자) → LNB(항목, 그룹, 하위) → Breadcrumb → 페이지 액션 → 검색 → 상태 → 초기화 → 표. focus trap은 Sheet/Dialog에서만.
- skip-to-content 링크는 만들지 않았다(§12). `aside`에는 이름이 없고 안의 `nav`가 이름을 갖는다.
#### 11. 비교 (ui-viz ↔ 현재 HM)
| 항목 | ui-viz | 현재 HM | 분류 |
|---|---|---|---|
| GNB 70 / LNB 272 | 같음 | `--hm-gnb-height` / `--hm-lnb-width` | MATCHED |
| 콘텐츠 면 색·padding 34 | `#F5F0E5`, 34 | 같음 | MATCHED |
| 접점 radius (LNB 18 / GNB 12), shadow | 같음 | 같음. 소유를 Dashboard로 이동 | MATCHED |
| 헤더·메뉴 고정, 콘텐츠만 스크롤 | 같음 | `DashboardMain` 스크롤 | MATCHED |
| 접점 띠가 콘텐츠 뒤에서 이어짐 | 형제 absolute | GNB/LNB `::after` + positioned Main | IMPLEMENTATION_CHANGE |
| Page heading 간격(하단 20, h 28, gap 24) | 같음 | `PageHeader` | MATCHED |
| Breadcrumb | 12px 정적 `p`, 컴포넌트 아님 | HM Breadcrumb(14px, 링크) | INTENTIONAL_CHANGE (사용자 지시) |
| 제목 태그 | `h2` | `h1` | INTENTIONAL_CHANGE (페이지당 h1) |
| 카드 | Content Card(흰색), padding 24 | HM Card | MATCHED |
| 조회 + 표 | 결과 카드 안, 검색 → 표 16 | Card 안 Filter Bar + Data Table, gap 16 | MATCHED |
| 표 density | ui-viz Table | HM Data Table(Table primitive) | MATCHED (primitive 기준) |
| GNB + LNB 동시 | 없음 | 조립(GNB 평평, LNB 아래) | INTENTIONAL_CHANGE / FOLLOW_UP |
| 반응형 | ≤1100px/48px OUT_OF_SCOPE | Dashboard 최소 폭 유지 + native 가로 스크롤. GNB 전역 Sheet 정책 유지 | INTENTIONAL_CHANGE |
| 스크롤바 | 기본 | 기본 | — |
| ui-viz 화면과 픽셀 비교 | — | 미실시(ui-viz Storybook 미실행) | — |
#### 12. 남은 차이 / 누락
| 항목 | 분류 |
|---|---|
| GNB+LNB brand 중복 | 해결 (2026-10-06, §4 Brand ownership: GNB만, LNBBrand 없음) |
| GNB+LNB 조립 시각 확정(두 gradient 이음새) | FOLLOW_UP — 사용자 결정 필요 |
| LNB only의 모바일 탐색 | 동일 Sidebar를 가로 스크롤로 탐색 (2026-10-07 최소 폭 정책, §9) |
| skip-to-content, `main` 이름 | FOLLOW_UP |
| ui-viz Variant C(Compact 48 + Status Filter Bar) | 대표 Story에 구현됨. 최소 폭 960px으로 유지 |
| 실기기 touch/trackpad, ui-viz 픽셀 비교 | 미확인 |
| 문서 스크롤 방식(frame 높이 없이 body 스크롤) 변형 | 필요 시 `--hm-dashboard-height: auto` + GNB `sticky`로 가능하나 검증하지 않음 |
##### 누락 컴포넌트 점검
| 항목 | 필요도 | 이유 | 다음 단계 |
|---|---|---|---|
| KPI Card | 권장 | Card+Badge+Progress를 4곳에서 직접 조합. 반복되면 Pattern | 실제 제품 요구 때 |
| Page-level Empty State | 권장 | 표 Empty만 있고 페이지 전체 빈 상태(첫 사용·권한 없음) 없음 | 요구 확인 후 |
| Page Loading | 후속 | Skeleton + Data Table `loading` 조합으로 충분(Story `Loading`) | 보류 |
| Bulk Action Bar | 권장 | 표 선택 후 일괄 작업 영역 없음 | Data Table 확장 때 |
| Filter Chip / 적용 조건 표시 | 후속 | Filter Bar 문서 §12에 기록됨 | 보류 |
| Mobile Filter Drawer | 후속 | Dashboard에서는 최소 폭 안에서 기존 Filter Bar를 유지 | 보류 |
| Notification Center, Global Search Result | 후속 | GNB 문서 | 보류 |
| Skip link | 권장 | 키보드 사용자: GNB/LNB 건너뛰기 | 접근성 보완 때 |
#### 13. 검증 (2026-10-07, 최소 폭 정책)
현재 검증 결과는 §9의 HORIZONTAL_SCROLL 정책을 따른다. 아래 Desktop 전용 정책 검증은 이전 작업 이력이다.
당시 Storybook 포트 6007, Chromium, 높이 900px에서 실측했다.
| viewport | Compact 내부 폭 | Compact 가로 스크롤 | GNB + LNB 내부 폭 | GNB + LNB 가로 스크롤 | 메뉴·콘텐츠 | 레이아웃 붕괴 |
|---|---:|---|---:|---|---|---|
| 1280 | 1265 | 없음 | 1280 | 없음 | 유지 | 없음 |
| 1024 | 1009 | 없음 | 1232 | 있음 | 유지 | 없음 |
| 1023 | 1008 | 없음 | 1232 | 있음 | 유지 | 없음 |
| 768 | 960 | 있음 | 1232 | 있음 | 유지 | 없음 |
| 390 | 960 | 있음 | 1232 | 있음 | 유지 | 없음 |
| 320 | 960 | 있음 | 1232 | 있음 | 유지 | 없음 |
- 대표 Compact Story는 바깥 Story 설명 때문에 문서에 세로 scrollbar 15px이 있다. 위 내부 폭은 이를 제외한 가용 폭이며 `100vw`를 사용하지 않는다. 모든 조합에서 문서 가로 넘침은 0px이다.
- Sidebar 폭 272px, Main padding 34px, Filter Bar 검색 폭 280px·높이 34px이 모든 폭에서 유지된다. KPI 4개는 작은 viewport에서도 한 줄, 각 약 201px이다. Main 자체 가로 넘침은 0px이다.
- Third Level, GNB Only, LNB Only, Long Content, Loading, Empty, Framed를 1280·320px에서 추가 확인했다. 문서 가로 넘침 0px, 넓은 1600px 내용은 카드 내부에서만 스크롤, Framed 높이는 scrollbar를 포함해 640px으로 유지된다.
- 320px에서 native 가로 wheel 입력으로 Compact scrollLeft 655px / Sidebar 구성 912px(각 최대값)에 도달하고 0px으로 복귀했다. 문서 scrollLeft는 0px으로 유지된다. 스크롤 root의 ArrowRight 입력도 동작한다.
- Compact Tab 순서 전체 → 활성 → 대기 → 비활성 → 검색을 확인했다. 비활성 버튼에 focus하면 scrollLeft 435px으로 자동 이동하며 버튼 전체와 focus indicator가 viewport 안에 보인다. 스크롤 JS, touch-action 제한, 입력 이벤트 차단은 추가하지 않았다. 실제 touch·trackpad·Shift+wheel 장치는 별도 실기기 확인이 필요하다.
- 일반 GNB Story의 모바일 높이 56px·desktop nav 숨김은 유지된다. 공통 GNB/LNB CSS와 JS는 수정하지 않았다.
- 실제 터치 장치 테스트는 수행하지 않았다.
- `npm run typecheck`, 최종 UI package build, Storybook static build, `git diff --check` 통과. 기존 문서 예시 `bg-[var(...)]` CSS 경고와 500kB chunk 경고는 유지된다.
##### 이전 Desktop 전용 정책 검증 (2026-10-07, Storybook / Chrome)
- Dashboard Story 9개 × 1280·1024·1023·390 = 36개 조합 통과. 대표 Compact 메뉴는 Desktop 표시, Mobile 높이 0. Sidebar는 Desktop 폭 272px, Mobile DOM에서 제거된다.
- 1280: GNB + LNB Main 폭 1008px. 1024: Main 폭 752px. 1023·390: Main이 각각 1023·390px 전체 폭을 사용하며 빈 sidebar 공간 없음. 모든 조합에서 문서 및 Main 가로 overflow 0.
- Mobile: Dashboard 로컬 메뉴·Compact 상태 필터가 접근성 tree에 없고 Tab으로 접근되지 않는다. GNB Sheet에는 기존 전역 메뉴만 표시하고 Dashboard 업무 메뉴는 없다.
- Desktop LNB hover·active 변경·그룹 열림/닫힘 통과. 열린 LNB Profile portal은 1024 → 1023 전환 시 함께 제거되고 1024로 복귀하면 sidebar가 다시 표시된다.
- 패키지 build·typecheck 통과. Story 수정 후 루트 `tsc -b` 통과. 브라우저 runtime error 없음.
##### 이전 검증 이력
아래 모바일 메뉴 관련 기록은 이전 정책의 이력이다. 현재 정책은 §9이며 모든 viewport에서 Dashboard 메뉴·콘텐츠를 유지하고 최소 폭 이하에서 가로 스크롤한다.
- 1280: 문서 스크롤 없음(`scrollHeight == clientHeight`), Main만 스크롤(scrollTop 400에서 GNB y 0·LNB y 70 유지). h1 1개, landmark 위 목록.
- 1024 desktop / 1023 mobile 전환, 768, 390: 문서·Main 가로 overflow 0. GNB-only, LNB-only, Long Content(긴 제목 + 1600px 내용) 모두 overflow 0.
- 390: 모바일 Sheet에 전역/로컬 그룹 표시, current 표시.
- 2026-10-06 `Third Level`: 1280·1024 GNB 70 + LNB 272(3뎁스 active "전체 업무", 상위 "업무 관리"·"업무 현황" current), 1023·768·390 GNB 56 + 햄버거, LNB `display:none`, Sheet에서 업무 메뉴 → 업무 관리(+12) → 업무 현황(+24) → 3뎁스 링크, active 동일. Sheet에서 다른 3뎁스를 고르면 LNB active도 같이 바뀐다. 5개 폭 모두 문서 가로 overflow 0.
- 2026-10-06 `LNB Only` 모바일: 1280·1024 GNB 없음 + LNB 272, radius `18 0 0 18`; 1023·768·390 상단 바 56(햄버거 1개, header 1개) + LNB 숨김, radius `12 12 0 0`, 가로 overflow 0. Sheet는 "업무 메뉴" 하나(전역 메뉴 없음), active 동일. `Dashboard Layout`·`Third Level`(GNB + LNB)은 5개 폭 모두 GNB 하나(1023 이하 56 + 햄버거 1개), Sheet 전역 + 업무 메뉴 그대로.
- 2026-10-06 Brand ownership: `Dashboard Layout`·`Third Level` 1280·1024 brand = GNB 1개(LNBBrand 없음, LNB 메뉴 top 98 = GNB 70 + 28), 1023·390 GNB 모바일 바 1개. `LNB Only` 1280·1024 LNBBrand 1개(상단 바 없음), 1023·390 Local 모바일 바 1개. 서비스명은 모든 폭에서 1개. 수치는 `lnb.md` §8.
- 기존 Pattern(GNB 70/12px 띠, LNB 272, Data Table, Filter Bar) Story 오류·overflow 없음.
- `npm run typecheck`, `npm run build` 통과.
#### 14. 관련 파일
```
packages/ui/src/patterns/dashboard-layout/{dashboard-layout.tsx,index.ts}
packages/ui/src/patterns/page-header/{page-header.tsx,index.ts}
packages/ui/src/theme/{dashboard-layout.css,page-header.css}
src/patterns/dashboard-layout.stories.tsx
```
</details>