style(storybook): tidy CENTER, chart, org chart and SW story pages

- CENTER plain page layout and shorter reference demo notes (spec values
  moved to the CENTER README); chart specimen descriptions trimmed
- Org chart page names and previews; SW width rows follow the 30px frame
  margin (1980 / 1065 / 727px) and the SW tests match the current layout
- Story inventory regenerated and WORKING_CONTEXT updated
This commit is contained in:
shkim4 committed 2026-11-13 17:58:01 +09:00
1 parent bad2acfc49
commit ed7e8790ec
40 files changed
+1486 -294

No files matched your search

+24 -18
View File
@@ -6,16 +6,16 @@ This is a classification record. No Story IDs, exports or titles are renamed. HI
## Summary
- PRIMARY: 82
- VERIFICATION: 256
- PRIMARY: 73
- VERIFICATION: 266
- UTILITY: 49
- LEGACY / DUPLICATE: 0
- KEEP: 82
- HIDE: 305
- KEEP: 73
- HIDE: 315
- MERGE_CANDIDATE: 33
- DELETE_CANDIDATE: 0
- Story files: 89
- Stories: 387
- Story files: 90
- Stories: 388
- Hierarchy: Foundation → Components → Patterns → Chart → SW → 조직도 → 노선도 → Verification → Theme
## Full inventory
@@ -29,7 +29,7 @@ Source: `src/center/center-facility.stories.tsx`; meta.tags: []
- Intersection — 교차로; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- Junction — 분기점; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- Interchange — 나들목; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- Roadside — 휴게소 · 졸음쉼터; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- Roadside — 연계 노선; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- Linked — 연계 시설물; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- TunnelPlayground — 직접 테스트 (터널); tags: ["verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
@@ -114,7 +114,7 @@ Source: `src/components/accordion.stories.tsx`; meta.tags: []
Source: `src/components/alert-dialog.stories.tsx`; meta.tags: []
- All — Alert Dialog; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- All — Alert Dialog; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- ConfirmationOpen — Confirmation; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- DestructiveOpen — Destructive; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
@@ -179,7 +179,7 @@ Source: `src/components/button.stories.tsx`; meta.tags: []
Source: `src/components/calendar.stories.tsx`; meta.tags: []
- All — Calendar; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- All — Calendar; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- SingleSelection — Single; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- DisabledSelection — Disabled Dates; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- RangeSelection — Range; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
@@ -207,7 +207,7 @@ Source: `src/components/carousel.stories.tsx`; meta.tags: []
Source: `src/components/checkbox.stories.tsx`; meta.tags: []
- All — Checkbox; tags: ["play-fn"]; PRIMARY; KEEP. Representative story or distinct reference page.
- All — Checkbox; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Unchecked — Unchecked; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Checked — Checked; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Indeterminate — Indeterminate; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
@@ -220,7 +220,7 @@ Source: `src/components/checkbox.stories.tsx`; meta.tags: []
Source: `src/components/collapsible.stories.tsx`; meta.tags: []
- All — Collapsible; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- All — Collapsible; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Closed — Closed; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Open — Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
@@ -249,7 +249,7 @@ Source: `src/components/command.stories.tsx`; meta.tags: []
Source: `src/components/context-menu.stories.tsx`; meta.tags: []
- All — Context Menu; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- All — Context Menu; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Open — Open; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
### Components/Search & Date/Date Picker
@@ -277,7 +277,7 @@ Source: `src/components/dialog.stories.tsx`; meta.tags: []
Source: `src/components/drawer.stories.tsx`; meta.tags: []
- All — Drawer; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- All — Drawer; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Open — Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Form — Form; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- LongContent — Long Content; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
@@ -293,7 +293,7 @@ Source: `src/components/dropdown-menu.stories.tsx`; meta.tags: []
Source: `src/components/hover-card.stories.tsx`; meta.tags: []
- All — Hover Card; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- All — Hover Card; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Open — Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
### Components/Form/Input
@@ -374,7 +374,7 @@ Source: `src/components/publisher-regression.stories.tsx`; meta.tags: ["verifica
Source: `src/components/radio-group.stories.tsx`; meta.tags: []
- All — Radio Group; tags: ["play-fn"]; PRIMARY; KEEP. Representative story or distinct reference page.
- All — Radio Group; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Vertical — Vertical; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
- Horizontal — Horizontal; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
- Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
@@ -420,6 +420,12 @@ Source: `src/components/select.stories.tsx`; meta.tags: []
- Scroll — Scroll; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Playground — Playground; tags: ["verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
### Components/Form/Selection Controls
Source: `src/components/selection-controls.stories.tsx`; meta.tags: []
- All — Selection Controls; tags: ["play-fn"]; PRIMARY; KEEP. Representative story or distinct reference page.
### Components/Data & Display/Separator
Source: `src/components/separator.stories.tsx`; meta.tags: []
@@ -468,7 +474,7 @@ Source: `src/components/sonner.stories.tsx`; meta.tags: []
Source: `src/components/switch.stories.tsx`; meta.tags: []
- All — Switch; tags: ["play-fn"]; PRIMARY; KEEP. Representative story or distinct reference page.
- All — Switch; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Off — Off; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- On — On; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
@@ -532,7 +538,7 @@ Source: `src/components/toggle-group.stories.tsx`; meta.tags: ["internal"]
Source: `src/components/toggle.stories.tsx`; meta.tags: []
- All — Toggle; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- All — Toggle; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- States — States; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
- IconOnly — Icon Only; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- DisabledToggle — Disabled; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
@@ -734,7 +740,7 @@ Source: `src/patterns/page-header.stories.tsx`; meta.tags: ["verification"]
Source: `src/sw/sw-footer.stories.tsx`; meta.tags: []
- FullFooter — SW 하단 명령어바; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- FullFooter — SW 명령어바; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- ToolGroups — 도구 그룹별 표시; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- FigmaVariants — Figma 디자인별 Variant (35 노드); tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- CustomContent — 도구 및 메뉴 변경 예제; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
+108 -52
View File
@@ -1,16 +1,16 @@
{
"summary": {
"storyFiles": 89,
"stories": 387,
"storyFiles": 90,
"stories": 388,
"categories": {
"PRIMARY": 82,
"VERIFICATION": 256,
"PRIMARY": 73,
"VERIFICATION": 266,
"UTILITY": 49,
"LEGACY / DUPLICATE": 0
},
"decisions": {
"KEEP": 82,
"HIDE": 305,
"KEEP": 73,
"HIDE": 315,
"MERGE_CANDIDATE": 33,
"DELETE_CANDIDATE": 0
},
@@ -90,7 +90,7 @@
{
"id": "center-graphic-facility--roadside",
"exportName": "Roadside",
"name": "휴게소 · 졸음쉼터",
"name": "연계 노선",
"storyTags": [],
"effectiveTags": [],
"category": "PRIMARY",
@@ -430,12 +430,16 @@
"id": "components-overlay-alert-dialog--all",
"exportName": "All",
"name": "Alert Dialog",
"storyTags": [],
"effectiveTags": [],
"category": "PRIMARY",
"decision": "KEEP",
"storyTags": [
"verification"
],
"effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Representative story or distinct reference page."
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{
"id": "components-overlay-alert-dialog--confirmation-open",
@@ -901,12 +905,16 @@
"id": "components-search-date-calendar--all",
"exportName": "All",
"name": "Calendar",
"storyTags": [],
"effectiveTags": [],
"category": "PRIMARY",
"decision": "KEEP",
"storyTags": [
"verification"
],
"effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Representative story or distinct reference page."
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{
"id": "components-search-date-calendar--single-selection",
@@ -1106,15 +1114,17 @@
"exportName": "All",
"name": "Checkbox",
"storyTags": [
"verification",
"play-fn"
],
"effectiveTags": [
"verification",
"play-fn"
],
"category": "PRIMARY",
"decision": "KEEP",
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Representative story or distinct reference page."
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{
"id": "components-form-checkbox--unchecked",
@@ -1232,12 +1242,16 @@
"id": "components-disclosure-collapsible--all",
"exportName": "All",
"name": "Collapsible",
"storyTags": [],
"effectiveTags": [],
"category": "PRIMARY",
"decision": "KEEP",
"storyTags": [
"verification"
],
"effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Representative story or distinct reference page."
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{
"id": "components-disclosure-collapsible--closed",
@@ -1451,12 +1465,16 @@
"id": "components-overlay-context-menu--all",
"exportName": "All",
"name": "Context Menu",
"storyTags": [],
"effectiveTags": [],
"category": "PRIMARY",
"decision": "KEEP",
"storyTags": [
"verification"
],
"effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Representative story or distinct reference page."
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{
"id": "components-overlay-context-menu--open",
@@ -1657,12 +1675,16 @@
"id": "components-overlay-drawer--all",
"exportName": "All",
"name": "Drawer",
"storyTags": [],
"effectiveTags": [],
"category": "PRIMARY",
"decision": "KEEP",
"storyTags": [
"verification"
],
"effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Representative story or distinct reference page."
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{
"id": "components-overlay-drawer--open",
@@ -1753,12 +1775,16 @@
"id": "components-overlay-hover-card--all",
"exportName": "All",
"name": "Hover Card",
"storyTags": [],
"effectiveTags": [],
"category": "PRIMARY",
"decision": "KEEP",
"storyTags": [
"verification"
],
"effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Representative story or distinct reference page."
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{
"id": "components-overlay-hover-card--open",
@@ -2346,15 +2372,17 @@
"exportName": "All",
"name": "Radio Group",
"storyTags": [
"verification",
"play-fn"
],
"effectiveTags": [
"verification",
"play-fn"
],
"category": "PRIMARY",
"decision": "KEEP",
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Representative story or distinct reference page."
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{
"id": "components-form-radio-group--vertical",
@@ -2802,6 +2830,28 @@
}
]
},
{
"file": "src/components/selection-controls.stories.tsx",
"title": "Components/Form/Selection Controls",
"metaTags": [],
"stories": [
{
"id": "components-form-selection-controls--all",
"exportName": "All",
"name": "Selection Controls",
"storyTags": [
"play-fn"
],
"effectiveTags": [
"play-fn"
],
"category": "PRIMARY",
"decision": "KEEP",
"candidate": null,
"reason": "Representative story or distinct reference page."
}
]
},
{
"file": "src/components/separator.stories.tsx",
"title": "Components/Data & Display/Separator",
@@ -3114,15 +3164,17 @@
"exportName": "All",
"name": "Switch",
"storyTags": [
"verification",
"play-fn"
],
"effectiveTags": [
"verification",
"play-fn"
],
"category": "PRIMARY",
"decision": "KEEP",
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Representative story or distinct reference page."
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{
"id": "components-form-switch--off",
@@ -3736,12 +3788,16 @@
"id": "components-form-toggle--all",
"exportName": "All",
"name": "Toggle",
"storyTags": [],
"effectiveTags": [],
"category": "PRIMARY",
"decision": "KEEP",
"storyTags": [
"verification"
],
"effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Representative story or distinct reference page."
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{
"id": "components-form-toggle--states",
@@ -5244,7 +5300,7 @@
{
"id": "sw-footer--full-footer",
"exportName": "FullFooter",
"name": "SW 하단 명령어바",
"name": "SW 명령어바",
"storyTags": [],
"effectiveTags": [],
"category": "PRIMARY",
+274
View File
@@ -1,5 +1,279 @@
# Working Context
## 2026-11-13 — 접기·펼치기 대표 Story 정렬 (미커밋)
- Variants → States → Collapsible · States → Content · Usage 순서로 배치했다. 공통 StateBoard의 24px 열 간격과 라벨 스타일을 재사용하고 대표 페이지의 섹션 간격만 24px로 맞췄다. 긴 예제 2개와 Button trigger는 마지막 섹션에 배치했다. 공개 컴포넌트와 별도 검증 Story는 그대로다.
- Chrome 6027에서 1920/1440/1024/760/390px 기준 3/3/2/1/1열, PC 열 시작선 일치, 긴 문단·영문 문자열 전체 표시 및 가로 스크롤 없음 확인. 클릭·키보드·Disabled·ARIA 연결, Story ID 10개, `tsc -b`와 관련 테스트 통과.
## 2026-11-13 — Combobox Usage 그룹 제목 제거 (미커밋)
- 대표 Story의 `Long option`·`Select / Combobox` 제목과 래퍼를 제거했다. 개별 라벨 5개, 기존 그리드·들여쓰기·Story ID를 유지했다. Chrome 6027에서 1440px 입력 3개 상단 일치, 1024/760/390px 기존 2/1/1열 및 가로 스크롤 없음 확인. Select 선택과 비활성 옵션, Combobox 검색·선택·포커스 복귀, `tsc -b`, 컴포넌트 테스트 9건 통과.
## 2026-11-13 — 날짜 선택: PC 열 정렬 및 반응형 정리 (미커밋)
- DatePicker 대표 Story 전용 4열 기준으로 Sizes·States·Usage와 Calendar의 시작선을 맞췄다. 섹션 간 24px, 열 간 16px. Usage 제목은 중복 라벨을 대신하며 입력창과 팝업의 접근성 이름으로 연결했다.
- Calendar의 Variants/States 섹션과 기존 Story ID는 유지했다. Two months는 아래 왼쪽에 배치하며, 넓은 화면에서는 기존 544px 너비, 좁은 화면에서는 기존 월 줄바꿈을 유지한다.
- Chrome 6027: 1920/1440에서 4열 기준선 일치, 1024에서 입력·달력 첫 행 2열, 760에서 입력 2열·달력 1열, 390에서 모두 1열. 전체 폭에서 가로 스크롤 없음. 1920×1080에서는 공통 열을 적용한 최종 콘텐츠 높이가 1134px로, Two months 하단까지 약 30px 스크롤 필요. 날짜 선택·비활성 조건·포커스 복귀·Story ID 확인, 타입 검사와 관련 테스트 통과.
## 2026-11-13 — 조직도 › 개요 → `가족사 현황`: 명칭 통일·로고 카드 테두리 제거 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 메뉴: `org-chart-p1.stories.tsx` title `조직도/가족사 현황`, Story name `가족사 현황`, `.storybook/preview.tsx` storySort 항목도 같은 이름(위치 첫 번째 유지). Story ID `orgchart--logo-personnel-summary`(`__id`)·export명 불변이라 기존 verify 스크립트·링크 영향 없음.
- 텍스트(`org-chart-page-previews.tsx`): 페이지 제목 `가족사 현황`, 섹션 `가족사 로고`·`가족사별 인원 현황`. `조직·직위별 인원 현황` 유지. 패키지 내부 문구(`logo-legend.ts`의 h2는 이 페이지에서 제거됨, `affiliation-bar` aria-label, 직원 상세 Dialog `관계사` 항목)는 공용 Core라 미변경.
- 테두리: `core-component-preview.tsx`의 `hideLogoHeading` 분기(사용처는 이 페이지 하나)에서 로고 `li`에 inline `border: 0`. padding 16px·로고 96×96·grid gap 16px 유지로 간격만으로 구분. 패키지 CSS·배경·그림자 미변경.
- 검증(6024 iframe, 1400·1024·760·390): 화면 텍스트 `관계사` 0, 로고 7개(순서 동일) border 0, 로고·회사명 중앙 정렬 오차 0, 열 6/5/3/1, 가로 Overflow 0. `tsc -b` 통과. 스크린샷은 pane 렌더 시간 초과로 미첨부. `.storybook/qa` 인벤토리(title·name) 재생성은 미실행.
## 2026-11-13 — 노선도 › 시설물 › 연계 시설물: 카드 배치 재구성 (POSITION | ACTUAL, 2열 카드) (미커밋)
- 범위: `src/center/center-stories.css`만(선택자 `.sb-center-linked-page` 범위). 패키지 Demo DOM·공개 API·SVG·Props·색상·다른 페이지·Story ID·메뉴 불변. 아래 "상단 안내·중복 명칭 제거" 기록의 숨김(안내·READY·카드 첫 이름)은 유지.
- 측정(17개 카드): POSITION 최대 240px(지방도 터널 분할), ACTUAL 최대 252px(교차로 계열) → 카드 내부 2열 240 + 24 + 252 = 516px, 카드 2열 516 × 2 + 24 = 1056px.
- 구조: 목록 `.linked-facility`를 container `sb-linked-list`로. 카드 그리드 기본 1열, ≥ 1056px 2열(gap 24, 홀수 마지막 카드 왼쪽). 카드 = 제목(14/20/700) + 패키지 `__variant` 2개를 열로: ≥ 516px POSITION | ACTUAL 좌우(subgrid로 라벨·기본·분할 행 정렬, 열 간 24), 미만 세로(블록 사이 24). 열 라벨 `POSITION` / `ACTUAL` 11px(사용자 A안 선택), 라벨 → 기본 8px, 기본 → 분할 16px(같은 행의 실사화 기본이 2px 더 높아 위치도 쪽 측정 18px). 카드 프레임 없음.
- 삭제: 기존 base `sb-linked-card` 좁은 카드 라벨 쌓기 규칙과 64px 라벨 열 규칙(연계 시설물 전용, 새 구조로 대체).
- 검증(6024 HMR, iframe): 1400(목록 1309) 카드 2열·내부 2열, 1024(933)·760(669) 카드 1열·내부 2열, 390(331) 카드 1열·내부 세로. 그룹 5개(5·3·3·3·3)·카드 17·figure 68·깨짐 0, 제목 중복 0, 안내·READY 미표시, 등급 제목 16px x=46(390: 28) → 카드 12px, 섹션·카드 간격 24, 행 정렬 불일치 0, figure 너비 불변(239·202·252·218), 로컬 스크롤 0, 문서 가로 Overflow 0. `tsc -b` 통과, `storybook-inventory.mjs` 1/1.
## 2026-11-13 — 노선도 › 시설물 › 연계 시설물: 상단 안내·중복 명칭 제거, 시작 위치·라벨 통일 (미커밋)
- 범위: `center-facility.stories.tsx`(Linked Story만 `sb-page-indent` 래퍼 추가), `center-stories.css`(선택자 `.sb-center-linked-page` 범위). 패키지 Demo·공개 API·SVG·Props·색상·토큰·다른 페이지·Story ID·메뉴 불변.
- CSS 숨김(실제 반복 표시만): 상단 안내 `.linked-facility__context`(문구 2개 + 회색 막대), 등급 제목 옆 `N READY 시설물 집합`(카드 개수 반복), 카드 첫 이름 `strong`(터널·교량·교차로는 한글 이름 반복, JC·IC는 그래픽에 표기). 카드 제목은 한글 이름 1회(14/20/700 primary).
- 정렬: 등급 제목·카드 시작 x = 터널 첫 제목(1400·1024·760: 46, 390: 28), 첫 제목 top도 터널과 동일. 카드 그리드의 자체 들여쓰기 제거.
- POSITION / ACTUAL: 다른 시설물 행 라벨과 동일(11px/700/.08em, 64px 열, 간격 16px). 좁은 카드 라벨 위로 쌓기 기준 365 → 331px(64 + 16 + 최대 252px 그래픽), 이 페이지 64px 규칙이 쌓기 규칙보다 우선하므로 scoped container 재정의 추가.
- 검증(6024 HMR, iframe 1400·1024·760·390): 안내·READY 미표시, 카드 제목 터널/교량/교차로/분기점/나들목 1회, 그룹 5개·카드 17개(5+3+3+3+3)·figure 68, 등급 제목 16/24/700 → 카드 12px, 섹션 24px, 카드 제목 → 그래픽 12px(760·390은 라벨이 위로 쌓여 41px, 기존 동작), 그리드 3/2/2/1열, figure 너비 불변, 이미지 깨짐 0, 로컬 스크롤 0, 문서 가로 Overflow 0. 1400 페이지 높이 3082 → 2838. `tsc -b` 통과, `storybook-inventory.mjs` 1/1.
## 2026-11-13 — 노선도 › 시설물: 교량·교차로·분기점·나들목·연계 노선·연계 시설물을 터널 레이아웃으로 통일 (미커밋)
- 범위: `src/center/center-facility.stories.tsx`, `src/center/center-stories.css`만. 패키지·dist·SVG·토큰·Story ID·메뉴명·순서·터널 화면 불변.
- 교량·교차로·분기점·나들목: 패키지 `FacilityReferenceDemo` 대신 공용 Story 컴포넌트 `FacilitySpecimens`(공개 API `LocalAssetFacility`·`FACILITY_CONFIGS`·`CompactFacilityConnection`, Demo와 같은 Props). Position Map | Actual / Detail 2열, 각 열 SEPARATED → SHORT/MEDIUM/LONG(ACTUAL SHORT/REF/LONG), 간략 적용 예제 하단 전체 너비(SOURCE 문구·bridge 구간 모양 유지). 상단 단독 위치도·실사 예제 = MEDIUM / ACTUAL REF 행(기본 이름·보조 문구 동일)이라 제거, Separated 예제는 각 열 첫 행으로 이동, `너비 동작` 제목 제거.
- 연계 노선: `RoadsideSpecimens`(공개 API `RoadsideFacilityTag`). 행 라벨을 다른 시설물과 같은 1줄 64px 라벨(SHORT/MEDIUM/LONG, ACTUAL …)로 공통화(사용자 요청), 열 보조 설명은 `위치도`/`실사화`만(사용자 요청으로 `콘텐츠 기반 너비`·`고정 양 끝 + 가변 중앙` 삭제). 분할은 실사화 첫 행, 2열에서 위치도 첫 행은 빈 행 `.sb-center-roadside__blank`, 1열에서는 숨기고 그룹 span 4. 위치도 태그는 `div`로 감쌌다 — 태그가 `span`이라 패키지 행 라벨 규칙(`.facility-reference__width-tests > div > span`, 11px)이 태그 글자에 적용되던 문제 방지.
- 연계 시설물: 패키지 Demo·카드 그대로. CSS만 — 등급 그룹 사이 28 → 24px, 그룹 제목 옆 설명 12/18 회색·간격 8px·왼쪽 정렬(기존 space-between 14px).
- CSS 전환 기준: 터널 1180px 블록을 `.sb-center-tunnel:not([data-sb-compare-at])`로 한정, 교량만 `data-sb-compare-at="1210"`(LONG 511px: 64+16+511 ×2 + 24 = 1206) 별도 블록. 연계 노선은 라벨 공통화로 64+16+450 → 1180px 사용(승인 시 1230px 안은 라벨 변경으로 불필요해져 제외). 빈 행 `display:none`은 패키지 `> div { display:grid }`보다 우선하도록 `.sb-center-roadside__position > ` 선택자.
- 검증(6024 HMR, 서버 재시작 없음, iframe 1400·1024·760·390): 7개 페이지 1400 2열·행 top 일치, 이하 1열(그룹 사이 24px, 겹침 없음), 제목 16/24/700 + 설명 12px 회색·간격 8px, 제목 → 첫 행 12px, 섹션 24px, 이미지 깨짐 0, figure 너비 변경 전과 동일, 390에서 stage 2개 로컬 스크롤(8px 규칙 유지), 문서 가로 Overflow 0. 1400 페이지 높이 교량 1299 → 900, 교차로·분기점·나들목 1046 → 900(뷰포트). `tsc -b` 통과, `packages/sw/tests/storybook-inventory.mjs` 1/1 통과. Storybook 전체 창(LNB 180px) 1400에서 교량은 콘텐츠 1210px 미만이면 1열.
## 2026-11-13 — 노선도 › 터널: `너비 동작` 제목 제거, 열 제목을 공통 섹션 타이틀로 (미커밋)
- `center-facility.stories.tsx`: `너비 동작 · CENTER만 수평 확장` 제목 행 삭제(stage는 `tunnel-reference__sample` 안에 유지). 열 제목을 `header > strong + span`으로 — `Position Map` + `위치도`, `Actual / Detail` + `실사화`.
- `center-stories.css`: 공통 예제 제목 행 규칙 3개의 `:is()` 목록에 `.sb-center-tunnel__width-group` 추가(16/24/700 + 12px 회색, 같은 줄, 간격 8px). 예전 열 라벨 규칙 삭제. 제목이 없어진 stage는 상단 padding 0, 열 제목 → 첫 행은 subgrid 16px − 4px = 12px.
- 검증(6024): 1400 두 제목 top·높이(24px) 동일, 4단계 행 정렬 유지. 1024·760·390 1열(그룹 사이 24px). 안내 문구 → 첫 제목 16px, 섹션 간 24px, 행 라벨 8개·figure 너비 불변, 390 stage 8px 스크롤바·끝까지 스크롤, 문서 가로 Overflow 0. `tsc -b` 통과.
## 2026-11-13 — 노선도 › 터널: 상단 영역 제거, 너비 동작 한 섹션으로 통합 (아래 "상단 3열" 기록을 대체, 미커밋)
- `center-facility.stories.tsx` `TunnelSpecimens`: 상단 Position Map / Actual / Separated 영역 삭제. `너비 동작` 각 열에 SEPARATED 행 추가 — 왼쪽 Position Map: SEPARATED → SHORT → MEDIUM → LONG, 오른쪽 Actual / Detail: SEPARATED → ACTUAL SHORT → ACTUAL REF → ACTUAL LONG. 삭제한 단독 위치도·실사 예제는 MEDIUM / ACTUAL REF와 같은 그래픽(대산2터널 L=750m). 간략 적용 예제는 하단 유지.
- `center-stories.css`: `.sb-center-tunnel__top`·`__stack`(3열 상단) 규칙 삭제, 그룹 subgrid 행 4 → 5. 1180px 2열/1열 전환·8px 스크롤바 규칙 유지.
- 검증(6024): 1400(콘텐츠 1324) 2열·4단계 행 위치 일치, 1024·760·390 1열(그룹 사이 24px). 섹션 2개(너비 동작·간략 적용 예제), 행 라벨 8개, 제목 → 그래픽 12px, 섹션 간 24px, figure 크기 불변(분할 197×93·199×95 등), 390에서 stage 8px 스크롤바·끝까지 스크롤, 문서 가로 Overflow 0. `tsc -b` 통과.
## 2026-11-13 — 노선도 › 터널: 상단 3열·너비 동작 2열 그룹 배치 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `src/center/center-facility.stories.tsx`: `Tunnel`만 패키지 `TunnelReferenceDemo` 대신 Story의 `TunnelSpecimens`(공개 API `TunnelFacility`·`CompactFacilityConnection`, 데모와 같은 Props·문구)로 구성. 패키지 클래스(`tunnel-reference__sample`/`__stage`/`__width-tests`)를 재사용해 공통 제목 행·12px·8px 스크롤바가 그대로 적용. Story ID·이름·순서·`TunnelPlayground`·다른 시설물 페이지 불변.
- `src/center/center-stories.css` 끝에 `.sb-center-tunnel*` 규칙만 추가(container query, 기존 8px 스크롤바 규칙 보존): 상단 Position Map | Actual / Detail | Separated(분할 2개 세로 16px) 3열 ≥ 720px, 미만 1열. 너비 동작은 열 제목 `Position Map`/`Actual / Detail`(14/20/500 회색) + SHORT·MEDIUM·LONG | ACTUAL SHORT·REF·LONG, subgrid로 같은 길이 행 정렬, 2열 ≥ 1180px(행 최대 577px × 2 + 24), 미만 1열(그룹 사이 24px). 간략 적용 예제는 아래 전체 너비.
- 검증(6024): 1400(콘텐츠 1309) 3열·2열·행 정렬 일치, 1024(933) 3열·1열, 760·390·320 1열·1열. 문구·라벨 누락 0, 제목 → 그래픽 12px, 예제 간 24px, figure 크기 모든 폭 동일(210×93 … 497×95), 390·320에서 너비 동작·간략 예제 stage 8px 스크롤바 + 끝까지 스크롤, 문서 가로 Overflow 0. `tsc -b` 통과.
- 테스트: `verify-p9-4.mjs`는 실행하면 untracked 기준 파일 `test-results/org-chart-p9-4/browser.json`을 덮어쓰므로 실행하지 않고, 터널 비교 항목만 브라우저에서 재현. 터널 첫 예제 프레임·stage 스타일 ≠ OrgChart Department(투명·padding 12px 0 0 vs 카드형) — 2026-11-12 노선도 카드 프레임 제거(`--plain`) 이후의 기존 차이이며 이번 변경과 무관(같은 클래스·같은 규칙).
## 2026-11-13 — SW Viewer 로고·문서 메뉴 테스트 잔여 7건: 승인 규격 기대값 반영 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `packages/sw/tests/viewer-logo-document-menu.browser.mjs`만 수정(SW 컴포넌트·Story·CSS·Props 불변). 7건 모두 2026-10-21~23 승인 변경 이전의 기대값이었다: 파일 열기 아이콘 = 실제 아이콘 1 + 외곽선 1 SVG(caf4c32), 아이콘 영역 16 × 16·클릭 영역(버튼) ≥ 34px(0840d76, 실측 104 × 50 / 아이콘만 37 × 50), 모든 sweep 폭에서 같은 규격; 좁은 헤더 hover = 면 없음(투명)·글자 #FFC600(caf4c32); sweep은 "More 전 아이콘 + 패널 토글이 전체 보기보다 먼저(2026-10-23) + 아이콘이 된 버튼은 좁아질수록 라벨 복귀 없음(More 이동 허용)", 항목 수 단조 감소 대신 More가 보이는 21개 폭(650~400)마다 More를 실제로 열어 8개 명령이 헤더·More 중 정확히 한 곳(패널 토글은 More 금지, 메뉴는 화면 안, Escape 닫힘, 페이지 넘침 없음), 파일명 길이와 무관하게 같은 전환 폭(아이콘 1040 / More 650, caf4c32의 Header 폭 기준).
- 결과: viewer-logo-document-menu 49/49(검사 1개 추가: ⑤를 순서·라벨 복귀 금지 두 검사로 분리), viewer 103/103, property-panel-comparison 59/59. 작업 중 새로 넣은 검사의 판정 오류 1건(More로 이동한 버튼을 라벨 복귀로 셈)은 라벨이 다시 보이는지로 정정.
## 2026-11-13 — 노선도 그래픽 내부 가로 스크롤 표시 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 점검: 10개 페이지 × 1400·1024·760·390. 레이아웃 컨테이너 잘림 0(시설물 탐지는 `visibility:hidden; position:fixed` 측정용 span), 인라인 SVG viewBox 초과 0, 깨진 이미지 0. "잘려 보임"의 원인은 실제 크기 그래픽이 `overflow-x: auto` stage 안에서 넘치는데 스크롤바가 overlay(높이 0)라 스크롤 표시가 없던 것.
- 수정: `src/center/center-stories.css` 끝에만 규칙 추가. `.sb-center--plain` 안의 `.tunnel-reference__stage`·`.facility-reference__stage`·`.center-title-reference__stage`·`.cid-demo__stage`·`.linked-facility__variant`·`.csi-panel > div`에 `::-webkit-scrollbar` 높이 8px, Thumb는 HM Scroll Area 토큰(`--hm-scroll-area-thumb-background`, hover `…-hover-background`), 트랙 투명. 비 WebKit은 `@supports not selector(::-webkit-scrollbar)`로 `scrollbar-width: thin` + 같은 색. overflow 동작은 그대로라 넘칠 때만 표시. 패키지·dist·다른 메뉴 불변.
- 검증(6024, Chromium 기반 앱 브라우저): 넘치는 stage 44개 전부 스크롤바 8px, 넘치지 않는 stage 스크롤바 0, 프로그램 스크롤로 끝까지 이동 44/44, 사업명 760에서 실제 마우스 가로 스크롤로 끝(205/205px)까지 이동·그래픽 오른쪽 끝 표시 확인. 제목 → 그래픽 12px·예제 간 24px 유지, figure 크기 모든 폭에서 동일, 문서 가로 Overflow 0. 미검증: Firefox·Safari(표준 thin 스크롤바 경로)와 OS overlay 설정이 다른 실제 사용자 브라우저.
## 2026-11-13 — SW 테스트 잔여 2건: SW 범위 Story 집합 검사, 로고 메뉴 More 정책 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `storybook-inventory.mjs`: 승인 Step Header Story 10개(`stepHeaderStoryIds`, adbb034)와 `swScopeIssues` 추가 — 제목이 `SW/`인 Story ID 집합이 "c8e5fbe 이후 유지된 SW/Header(승인 변경 반영) + 승인 추가 목록"과 정확히 같은지 검사(unexpected = 무단 추가·ID 변경, missing = 삭제·ID 변경). `property-panel-comparison.browser.mjs`: 전체 Story 수 공식(338 − 9 + 30)을 `swScope.ok`로 교체(그 1줄과 import만; 공식이 이 파일에 있어 함께 수정). SW 밖 Story 추가·삭제는 영향 없음, 태그(internal·verification) 검사는 기존 header 검사가 유지 — 메모리 시뮬레이션으로 SW 무단 추가·삭제·ID 변경·태그 제거 감지 확인.
- `viewer-logo-document-menu.browser.mjs`: 2026-10-26 More 정책(약 520px 미만에서만 More)에 맞춰 More를 여는 세 곳을 400px로(520 행을 제자리에서 400px로 줄임 / 헤더 400px, viewer.browser.mjs와 같은 방식), 860·760·520 기대를 "아이콘만, More 없음"으로, 항목 1회 보유 검사는 400px 축소 상태에서 수행 후 복원.
- 결과: property-panel-comparison 59/59(swScope 50 = 50), viewer 103/103, viewer-logo-document-menu 41/48(이전엔 1단계에서 중단). 남은 7건은 이번에 처음 실행된 검사의 옛 기대값: 파일 열기 아이콘 34px 기대 ↔ 현재 16px(3건), 좁은 헤더 hover 색(1건) — 2026-10-21 이후 Viewer Header 수정; sweep 3건 — 1050~1310px에서 패널 토글만 아이콘(전체 보기 라벨 유지, 2026-10-23 승인), 560px 미만 파일명 56px로 항목이 More에서 돌아옴(비단조), 파일명 폭 고정으로 길이와 무관(2026-10-26). 제품 코드 미변경, 판정 재정의는 별도 승인 필요.
## 2026-11-13 — SW 테스트 기존 실패 정리: 인벤토리 internal 태그·Dropdown 스크롤·로고 메뉴 뷰포트 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 테스트 파일 3개만 수정(SW 컴포넌트·Story·CSS·Story ID 불변). `packages/sw/tests/storybook-inventory.mjs`: 승인 commit 2378663(2026-11-09)이 `sw-header--logo-slot`에 붙인 `internal` 태그를 `internalTaggedIds`로 기대값에 반영(`withApprovedEntryChanges`). `property-panel-comparison.browser.mjs`: "Dropdown follows the page scroll" 검사를 반복문 밖 별도 단계로 옮겨 1440 × 600(페이지가 스크롤되는 높이)에서 `behavior: "instant"`로 실행 후 크기·스크롤 복원, 판정 조건(이동 > 0, 필드 부착 1.5px 이내) 그대로. 원인: 3열 배치 이후 1440 × 900에서 페이지가 화면보다 짧고, `src/hm-theme.css`의 `html { scroll-behavior: smooth }`로 직후 scrollY가 0. `viewer-logo-document-menu.browser.mjs`: 화면 너비 페이지를 여는 두 곳을 1920 → 2500px(1920 행은 1980px 이상 페이지에서만 표시, 정책 유지).
- 결과: property-panel-comparison 58/59(이전 56/59, Dropdown 스크롤·인벤토리 태그 해결), viewer 103/103(회귀 없음). 남은 실패: ① 인벤토리 첫 검사의 전체 Story 수 조건(388 ≠ 338 − 9 + 30 = 359) — SW 변경만 반영하는 공식이라 2026-10-20 이후 추가된 비 SW Story(Chart·Selection Controls 등)를 세지 못함, 기존 문제·승인 범위 밖. ② viewer-logo-document-menu: 시간 초과는 해결됐지만 다음 단계가 520 행의 More(추가 도구)를 누르는데 2026-10-26 규칙(More는 약 520px 미만에서만, viewer.browser.mjs는 520 행을 400px로 줄여 검사)으로 520 행에 More가 없어 중단. 테스트 기대가 2026-10-21 규칙(860·760·520에서 More) 기준이라 판정 조건 갱신이 필요하며 이번에는 수정하지 않음. 이후 검사는 실행되지 못함. 정적 빌드가 필요한 테스트(dropdown-consolidation·overview·storybook-unused-removal)는 실행하지 않음.
## 2026-11-13 — 노선도 데모 설명 정리: 기술·제작 설명 제거, 규격은 문서 보존 (브랜치 `style/storybook-ui-refinements`, 미커밋, 패키지 수정 승인됨)
- 패키지 `packages/center-graphic/src/components`의 `*ReferenceDemo` 문자열만 수정(컴포넌트 API·Props·CSS·그래픽·`index.ts` 불변, CRLF 유지): `TunnelFacility.tsx`·`FacilityBatch.tsx`(로컬 SVG → 위치도/실사화, Separated 설명 제거, `Source 정의` 제거, 연계 노선 `재구성 Primitive`·`Source 작성 …`·`13 / 90 / 13` 제거, `태그 계약` → `태그`), `CenterInformationDisplay.tsx`(정의 목록 구조 설명·테이블 행 높이·강조형 1 겹침 설명 제거, `Source 팔레트` → `팔레트`, 동적 콘텐츠 → `한 줄 항목은 말줄임, 명시적 줄바꿈 유지`), `CenterSpatialInformation.tsx`(1·1b legend 제거, 2·3 legend는 사용 기준 문장만). 유지: `SOURCE_RECEIVED`/`SOURCE_PENDING` 설명, `KNOWN_DEVIATION`, `READY`, 너비 테스트 라벨, 4·5·6·7 사용 기준·미확정 표기, 노선·공간 상태·입력. `npm run build:center-graphic` 1회(dist는 Git 미추적).
- 문서: `docs/center-graphic/README.md` §4에 화면에서 뺀 수치를 코드와 대조해 추가(윤곽 3.6u, 그림자 Blur 6u 70%, Dark/White Outline 분류, 공사중 32u/32u·계획 45° 빗금, 사업 노선 #D21203·150%, 노선명 54u/9u, 번호 64u, 방면 50u·80% 2u·7점, 정보 테이블 26.116/34.364u(=26.116 + 8.248), 강조형 1 4u 겹침, 연계 노선 간격 19/14). `13 / 90 / 13`은 코드 미확인 Source 표기로 비확정 명시. §7 공통 레이아웃 문단 갱신.
- 검증(6024, 10개 페이지 × 1400·1024·760·390): 제거 대상 문구 23종 잔존 0, 필수 상태·비교 문구 누락 0, 제목 → 그래픽 12px, 노선·공간 h4 → 캔버스 12px(설명 있으면 4px), 안내 오른쪽 끝, 문서 가로 Overflow 0, 깨진 이미지 0. `tsc -b`·center-graphic `typecheck` 통과.
## 2026-11-13 — Storybook 미해결 사항: SW 화면 너비·390px 넘침, Input States, Selection Focus, 문서·주석 정합성 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- SW 화면 너비 4개 페이지(SW 헤더·뷰어·단계별 헤더·명령어바 › 화면 너비): 행 제목의 폭과 실제 폭이 같도록, Story 열보다 넓은 행은 숨기고(`data-sw-width-row`) 페이지의 가장 좁은 행(760 / 520 / 1100, `data-sw-width-min`)은 항상 표시하며 열이 더 좁으면 그 폭을 유지한 채 행 안에서 스크롤한다(`sw-stories.css` 컨테이너 `sw-story-rows`, `.sw-story-fit__scroll`). 최소 폭·overflow는 열이 좁을 때만 적용되어 넓은 화면의 열린 메뉴가 잘리지 않고 행을 제자리에서 줄이는 검사(viewer More)도 그대로 동작한다. 각 페이지의 너비 세트는 유지. 명령어바 화면 너비는 다른 세 페이지처럼 `sw-story-stack`(flex gap)으로 행 간격을 두고 형제 margin 규칙 2개를 삭제했다(`sw-footer-stories.css`).
- SW 명령어바(760)·뷰어 로고(520): Story 열이 그보다 좁으면 예제가 그 폭을 유지하고 섹션 안에서 스크롤(`.sw-story-fit`), 페이지 가로 넘침 없음. SW 설정창: 액자 최소 520px(이미 있던 스크롤 영역 안)로 390px에서도 340px 옵션 팝업이 액자 안에 들어간다. SW 컴포넌트 크기·최소 폭·API는 변경 없음.
- Input States: Disabled를 Focus·Validation과 같은 Medium(34px)으로. Readonly는 컴포넌트 규칙(텍스트만, 항상 Large 수치)이라 42px 유지.
- Selection Controls Focus: `.storybook/pseudo-states.ts`에 `:focus-visible` → `data-sb-focus` 복제를 추가(부정형 `:not(:focus-visible)` 규칙은 복제하지 않음). Radio Group(Option A)·Switch Focus 칸에 링 고정, Checkbox는 기존 `play` 실제 포커스 유지. 실제 Tab 포커스 링 동작 동일.
- 후속 수정(2026-11-13): 위 복제가 `:focus-visible:active` 같은 복합 규칙을 `[data-sb-focus][data-sb-active]`로만 만들어 Slider `Focus` 검증 Story의 `Focus + Drag`(실제 포커스 + `data-sb-active`)가 실패했다. 기존 복제본(`:focus-visible` 유지, hover/active만 교체)도 함께 만들도록 보완. 검증: Slider Focus play 통과, Selection Controls·Button·Input·Checkbox에서 복제 규칙 삭제 0·추가 9개(모두 기존 방식 복제본), Selection Controls 고정 포커스 링 동일, `tsc -b`·Storybook build 통과.
- 문서: 프로젝트-지침 §6(구분선·16px 제거, 좌우 최대 30px), combobox·dialog·dropdown-menu·toggle·sonner·navigation-menu·lnb·checkbox·radio-group·switch·input, chart README 높이. 주석: foundation.css·center-stories.css(1줄)·chart-story·story-board·Story 공통 문구의 "divider", tabs 28px, carousel 중복 주석, org-chart-expanded의 적용되지 않던 `margin-top: 24px`(계산값 0px) 삭제.
- SW 테스트: 인벤토리 기대 이름을 현재 Story 이름(속성창·화면 너비·설정창·로고·SW 명령어바)으로 갱신. property-panel-comparison 56/59(남은 실패: 인벤토리 2건 = `sw-header--logo-slot`의 `internal` 태그(258e0a4)가 승인 태그 목록에 없음, Dropdown 페이지 스크롤 1건 — 모두 기존), viewer 103/103. viewer-logo-document-menu는 1920px 화면에서 숨겨진 1920 행의 헤더를 기다리다 시간 초과(2026-10-23 이후 기존 문제).
## 2026-11-13 — 노선도 메뉴 전체에 사업명 페이지 레이아웃 공통 적용 (A안, 브랜치 `style/storybook-ui-refinements`, 미커밋)
- Story: `center-facility.stories.tsx`(`page()` — 터널·교량·교차로·분기점·나들목·연계 노선), `center-information.stories.tsx`, `center-spatial.stories.tsx`에서 `StorySection title="개요"` 제거 → 공통 `sb-page-indent`로 기존 예제 들여쓰기 유지. 연계 시설물(원래 개요 없음, 들여쓰기 없음)·사업명은 불변. Story ID·이름·순서·Playground 불변.
- CSS(`center-stories.css`, `.sb-center--plain` 범위): 사업명 전용 제목 행 규칙을 공통 규칙 하나로 통합 — 터널·시설물·사업명·정보 데모 헤더(`header > strong|h3 + span`)를 한 행(flex-wrap, baseline, 간격 8px)으로, 제목 16/24/700 primary, 설명·사이즈 12/18 secondary. 노선·공간 `h4`도 16/24/700(긴 설명 legend/note는 제목 아래 14px 그대로). 연계 시설물 카드 라벨은 기존 14/500 유지. 배율 안내 `.center-representation__note`는 독립 행 오른쪽 끝(flex-end, 줄바꿈), 사업명만 숨기고 첫 제목 행에 표시(숨김 선택자 특이도 상향). 섹션 제거로 사용처가 없어진 `.sb-page-section-body > .sb-center--plain` 들여쓰기와 사업명 헤더 `> div` gap 규칙 삭제. 패키지 문구(Source·SOURCE_RECEIVED/PENDING·번호)는 그대로.
- 문서: `docs/center-graphic/README.md` §7에 공통 레이아웃 기준 한 단락.
- 검증(6024, 페이지 10개 × 1400·1024·760·390·320): `개요` 0, 안내 오른쪽 끝 = Inner 오른쪽 끝(사업명은 제목 행), 모든 예제 헤더 16/24/700 + 12px·8px, 제목 → 그래픽 12px, 노선·공간 h4 16px/700, 문서 가로 Overflow 0. 1400·1024는 전 헤더 한 줄, 760 정보 1개, 390·320은 긴 설명(정보·연계 노선 등)이 다음 줄로 줄바꿈. 교량 1024 예제 간 24px·열 간 24px. `tsc -b` 통과. 그래픽·stage 규칙은 미변경.
- 미해결: 안내 문구의 첫 제목 행 배치와 Source 문구 정리는 패키지 데모 공통화 작업에서 별도 검토.
## 2026-11-13 — 노선도 › 사업명: 배율 안내를 첫 예제 제목 행 오른쪽으로 이동 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 아래 "`개요` 제거·우측 정렬" 기록의 후속. 패키지 `CenterRepresentation`의 독립 안내(`.center-representation__note`)는 이 페이지에서만 `display: none`(`.sb-center-title-page`). 같은 문구를 `center-title.stories.tsx`의 첫 예제 제목 행에 `.sb-center-title-note`로 렌더(문구는 `centerTargetResolution()` 기준 100% · 7680 / 75% · 3840, 패키지와 같은 규칙).
- `center-stories.css`: 이전 우측 정렬 규칙을 위 숨김 + `.sb-center-title-note { margin-left: auto; text-align: right }`로 교체. 줄바꿈되는 flex 행에서 "구성명 + 사이즈 | 안내" 양끝 정렬, 공간 부족 시 안내만 다음 줄 오른쪽으로 이동. 스타일은 사이즈와 같은 12px 회색.
- 검증(6024): 1400·1024·760·390 모두 안내가 첫 제목 행과 같은 줄, 오른쪽 끝 = Inner 오른쪽 끝(1355 / 979 / 730 / 374). 별도 안내 영역 없음(첫 제목 행이 페이지 상단 gutter에서 시작). 예제 5개 제목→그래픽 12px·간격 8px·예제 간 24px·그래픽 크기 불변. 320px 추가 확인: 제목 + 사이즈는 한 줄, 안내만 다음 줄 오른쪽. 문서 가로 Overflow 없음, `tsc -b` 통과. 패키지·dist 미수정.
## 2026-11-13 — 노선도 › 사업명: `개요` 타이틀 제거·배율 안내 우측 정렬 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `src/center/center-title.stories.tsx`의 `Compositions`: `StorySection title="개요"` 제거. 예제는 공통 `sb-page-indent`(16px, ≤640px 12px) 안에 둬 기존 x 위치를 유지한다. `StoryPage className="sb-center-title-page"` 추가. Story ID·`Playground`·예제 5개 구성은 불변.
- `src/center/center-stories.css` 끝에 `.sb-center-title-page .center-representation__note` 규칙만 추가(`display:flex; justify-content:flex-end; text-align:right; overflow-wrap:anywhere`). 패키지 `CenterRepresentation`·dist·다른 노선도 페이지는 불변(클래스가 이 Story에만 있음).
- 검증(6024): 1400·1024·760·390 모두 H2 0개·`개요` 텍스트 없음. 안내 문구 오른쪽 끝 = Inner 오른쪽 끝(1355 / 979 / 730 / 374), 한 줄. 첫 예제 제목은 안내 문구 16px 아래(패키지 note margin), x = Inner + 16(390은 +12)로 이전과 같다. 예제 5개 한 줄·간격 8px·제목→그래픽 12px·예제 간 24px·그래픽 크기 불변(390의 긴 콘텐츠 설명 줄바꿈은 이전과 같음). 문서 가로 Overflow 없음. `tsc -b` 통과.
## 2026-11-13 — 노선도 › 사업명: Type·Source 문구 제거, 구성명 + 사이즈 한 줄 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `src/center/center-title.stories.tsx`의 `Compositions`(Story ID `center-graphic-title--compositions` 유지)만 변경. 패키지 데모 `CenterTitleReferenceDemo` 대신 공개 API(`CenterTitle`, `CENTER_TITLE_COMPOSITIONS`)로 예제 5개를 Story에서 직접 구성한다. 제목 = 구성명(H3), 오른쪽 사이즈(`620 × 100` 등). 긴 콘텐츠는 제목 `노선구분 + 사업명 (긴 콘텐츠)` + 기존 설명 "같은 연결과 고정 외곽 지오메트리; 콘텐츠 기반 너비". `Type 1~4`·`Source 근거 뷰포트` 표시 제거. 패키지(`packages/center-graphic`)·dist·`Playground`·상단 `표시 배율 · 제작 대상` 안내는 불변.
- `src/center/center-stories.css` 끝에 `.sb-center--plain … header.sb-center-title-row` 규칙만 추가: 제목은 공통 섹션 제목(16/24/700 primary), 사이즈는 12/18 secondary, 간격 8px, 모든 폭에서 한 줄(공간 부족 시에만 줄바꿈 — 패키지의 ≤720px 세로 배치를 이 페이지에서만 덮음). 제목 → 그래픽 12px(기존 stage 상단 padding), 예제 간 24px(기존) 유지. 그래픽 영역 클래스 `.center-title-reference__stage` 재사용으로 배경·크기 불변.
- 검증(6024): 1400·1024·760에서 5개 모두 한 줄·간격 8px·제목→그래픽 12px·예제 간 24px, 그래픽 620×100 / 610×167 / 890×100 / 782×167 / 긴 콘텐츠 1695×100(기존처럼 stage 로컬 스크롤). 390에서 고정 사이즈 4개는 한 줄, 긴 콘텐츠 설명만 다음 줄로 넘어감(의도된 줄바꿈). 모든 폭에서 문서 가로 Overflow 없음, Type·Source 문구 0. `tsc -b` 통과.
## 2026-11-13 — Storybook 전체 점검 후속: P1 4건·중복 UI 요소 제거 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 원칙(사용자 정정): 중복 정리는 문구 번역·재작성이 아니라 같은 정보를 반복하는 UI 요소 자체를 제거하는 것. 버튼·폼 Label·aria-label·필요한 H2/H3는 유지하고, 차트처럼 한 행에 놓인 예제는 정렬을 유지한다. 번역·`열기` 일괄 치환·Title Case·Invalid→Error·Usage/Default 명칭 변경은 원복했다.
- P1: Toggle Button Sizes 라벨 Default→Medium·글자 토글의 prop 값(`sm/default/lg`)→예시 문구 "알림"(`toggle-examples.tsx`), Collapsible 대표 화면 디버그 출력 제거(`collapsible-examples.tsx`), Combobox·Date Picker Sizes `Default`→`Large`(기본 크기가 실제 large), SW 설정창 패널 배치를 Story 열 폭에 따라 3/2/1열로(1076px 이상 기존 24/368/712, 700px 이상 2열, 미만 1열; `sw-viewer.stories.tsx`, 1024px 겹침 해소, 390px에서 340px 옵션 팝업은 프레임보다 7px 넓음).
- 중복 제거: 비활성 항목의 "(사용 불가)"(메뉴바·팝업 메뉴·컨텍스트 메뉴·명령어 검색), Breadcrumb "홈 (custom link)"→"홈", Toggle "Disabled off/on"→"Off/On", SW 뷰어 속성 "일반 정보 (General)"·"기하 속성 (Geometry)"의 괄호 영문, Date Picker Label "기본 형식 · " 접두어, 차트 설명 줄(H2 반복만 있는 Line 6·Area 곡선 6·Donut 4·Radar 4 전체 제거, Bar Horizontal "· 가로 막대"·Radial 내부 ID 접두어만 제거, 정보가 섞인 Pie·Area Stacked/Expanded는 정렬 때문에 유지), SW 속성창 비교 figcaption 4개(상태명은 figure `aria-label`).
- 기타: 라디오 그룹 aria-label이 내부 id(`sc-d`)로 읽히던 문제 → "옵션 선택", slider 소스 공백 오타, alert-dialog 주석 정정. 테스트 `packages/sw/tests/property-panel-comparison.browser.mjs`: figcaption 대신 figure aria-label도 읽고, 1열/3열 기준을 현재 CSS(727 / 1065px, 좌우 여백 30px)에 맞춤.
- Selection Controls H2("Selection Controls")와 Step Header H2는 섹션 구조(제목 위계)에 필요해 유지. Story ID 388개·Props·컴포넌트 불변. 문서: button.md·toggle.md·combobox.md·date-picker.md 해당 문장.
## 2026-11-13 — 이동 경로(Breadcrumb) Story: Custom 명칭·Variants 1/3열·Usage 전체 너비 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `src/components/breadcrumb.stories.tsx`의 대표 `All`만 수정. 예제 이름 `Custom link · separator` → `Custom`(id `custom` 유지). Variants 배치를 `auto-fill`(최소 240px) → `grid-cols-1` + `min-[1000px]:grid-cols-3`(Accordion·Collapsible과 같은 1 → 3열 패턴, 2 + 1 줄 배치 제거). Usage(Long path)의 `max-w-[900px]` 제거 → 섹션 전체 너비, `BreadcrumbList` `flex-wrap`으로 자연 줄바꿈. 공통 `StorySection` / `StateBoard` 재사용, 페이지 전용 CSS 없음. Story ID·예제 수·verification Story·Breadcrumb 컴포넌트·Props·Theme·토큰 불변. `docs/components/breadcrumb.md` §9 대표 Story 설명 갱신.
- 검증(6027 임시 서버 `storybook-verify`): iframe 1400 = 3열(각 425px) / 1024 = 3열(각 300px), 760 / 390 = 1열. 세 예제 모두 제목 → Breadcrumb 12px, 시작 위치 같음, 섹션 간격 28px. Long path는 1400·1024에서 1줄, 760에서 2줄, 390에서 3줄이며 넘침·문서 가로 스크롤 없음. 마지막 경로 700·`rgb(33,33,33)` 유지. Ellipsis Dropdown은 열림(`문서`·`설정`) → Esc 닫힘 → 트리거로 Focus 복귀, 링크 href·`/` 구분자 유지, 콘솔 오류 없음. `tsc -b` 통과.
## 2026-11-13 — 탭 Story: Variants 2열·Behavior(Overflow) 분리·Usage 반응형 2열 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `src/components/tabs.stories.tsx`의 대표 `All`만 재배치. Variants = Primary / Secondary 균등 2열, 새 H2 **Behavior** = 기존 Overflow 예제(20개 탭, 내용 불변)를 왼쪽 열에, Usage = 기존 두 예제(Card 흰 배경·페이지 배경·`max-w-lg`·입력 요소 불변) 균등 2열. `ThreeColumns` 제거, `TwoColumns`를 `@container` + `@min-[720px]` 2열(24px 간격)로 변경 — 기준은 섹션 본문 실제 너비(공통 board 들여쓰기 16px 제외). 공통 `StorySection` / `StateBoard` 재사용, 페이지 전용 CSS 없음. Story ID·`data-overview-state`·verification Story·Tabs 컴포넌트·Props·Theme·토큰 불변. meta 설명 문구와 `docs/components/tabs.md` 대표 Story 설명 갱신.
- 검증(6027 임시 서버 `storybook-verify`, 6024는 다른 세션 서버로 `storybook-stories.js` 404): iframe 1400(컨테이너 1309) / 1024(933) = 2열(각 642 / 454px, 제목·탭 시작점 동일), 760(685) / 390(346) = 1열, 문서 가로 스크롤 없음. 탭 클릭 선택·패널 전환(Primary·Secondary·Overflow), Overflow 다음 버튼 `scrollLeft` 증가·이전 버튼 활성화·선택 유지. `tsc -p tsconfig.app.json --noEmit` 통과.
## 2026-11-13 — 선택 컨트롤 상태 비교 매트릭스 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `src/components/selection-controls.stories.tsx`만 재작성(+ `checkbox-examples.tsx`의 `Controlled` export). 반복 H2 6개("Checkbox · States" 등) → H2 `Selection Controls`(상태 매트릭스) + H2 `Variants`(Checkbox Indeterminate, Radio Group Vertical/Horizontal; Switch는 별도 Variant 없음).
- 매트릭스: 행 Default / Hover / Focus / Disabled / Validation, 열 State / Checkbox / Radio Group / Switch. Radio Disabled는 캡션 `항목`·`그룹`으로 구분, Switch Validation은 `—`(상태 없음). Radio Error 시안은 선택 없음(`defaultValue=""`, 사용자 요청 — Error 행에 초록 선택 표시 없음). 컴포넌트·Props·Theme CSS 불변, Hover는 기존 `data-sb-hover`, Focus는 기존처럼 Checkbox만 play에서 자동 Focus.
- 반응형: DOM 1회 렌더(컴포넌트 순서) + 컨테이너 쿼리(`@container`, 미리보기 Inner 실제 너비). Inner ≥ 720px이면 각 컴포넌트 블록이 `display: contents`로 4열 grid에 배치(열 `max-content` + 남는 폭 1fr 균등, 행 구분 `border-border` 1px, 셀 12px 상하·24px 열 간격). 미만이면 Checkbox → Radio Group → Switch, 각 Default → Validation 세로 그룹(상태명 12/16/400). 4열 콘텐츠 실측 합계 692px(State 92 + 232 + 217 + 151) → 여유 28px로 720px.
- 검증(6026, 6024는 기존 `/index.json` 500 지속): iframe 1400·1024 = 4열, 760·390 = 세로 그룹, 문서 가로 스크롤·잘림·화면 밖 요소 0. 인스턴스 Checkbox 9·RadioGroup 8·Switch 7(중복 렌더 없음), `data-overview-state` 19개, Hover 시각(테두리 색) 유지, Manager 1024 창(LNB 180)에서도 4열. `tsc -p tsconfig.app.json --noEmit` 통과.
- 남은 정리 후보: `*Sections`의 `titlePrefix`·`idPrefix`·`gridClassName`는 이제 사용처 없음(숨김 개별 페이지는 미사용) — 범위 밖이라 유지.
## 2026-11-13 — 통합 페이지 디자인 정합성: 버튼·Toggle Button 규칙 통일, 선택 컨트롤 공통 그리드 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 버튼 페이지 Toggle Button 재구성(같은 날 후속, 아래 `flat` 기록을 대체): `flat`이면 `ButtonPageToggleSection` — 독립 H2 "Toggle Button" 한 섹션(버튼 열과 36px: 섹션 간격 28px + `pt-2`), 그룹 H3 Variants / States(Off / On, Disabled) · Groups(단독, 단일 선택, 다중 선택 — 3차: 버튼 페이지만 `groupSpecimens(idPrefix, true)`로 "연결 그룹 · " 접두어 생략, 숨김 Toggle 페이지 라벨 유지) · Sizes(Small/Default/Large). 버튼과 통일(같은 날 2차): 1400px 이상 같은 너비 3열(버튼 열과 같은 x), 미만 1열·그룹 간 24px. 각 행은 States 매트릭스 형식(왼쪽 라벨 14/20/500 → 8px → 컨트롤, 행 간 8px, subgrid로 그룹 안 컨트롤 시작선 정렬). `ToggleSections`에 `stacked` 옵션 — 버튼 페이지가 `MatrixFit` 안의 `ToggleButtonSection`에서 넘겨, 매트릭스가 세로 배치되면(390px) Toggle도 라벨 위 12/16/400 → 4px → 컨트롤, 행 간 12px로 함께 바뀐다. 반복 제목 `Toggle Button · …` 제거, 예시 ID(`toggle-offon` 등) 유지. 숨김 Toggle 페이지 경로(4섹션·StateBoard·Sizes 들여쓰기)는 그대로이며 `groupSpecimens`·`SizeRow`만 공유. 검증 1440/1400/1399/1100/1099/1024/768/390 가로 스크롤·잘림·줄바꿈 0, 상호작용 정상, `tsc -p tsconfig.app.json` 통과.
- (이전 기록) 버튼 페이지 Toggle Button 섹션: `ToggleSections`에 `flat` 옵션(버튼 페이지만). H3 그룹 라벨을 버튼 GroupBlock 규칙(들여쓰기 0, H3 → 예시 8px)으로 그리고 Sizes 들여쓰기도 뺀다. 1400px 이상에서는 Variants·States가 버튼 3열 트랙의 1·2열을 써서 단독/연결 그룹·Sizes와 함께 Brand / Standard Solid / Standard Outline 열에 맞는다(1000~1399px는 기존 324:234 비율 2열, 999px 이하 1열). 섹션 제목 `Toggle Button · 단독 · 연결 그룹` → `Toggle Button · 단독/연결 그룹`. 숨김 Toggle 페이지는 기존 StateBoard(16px·12px)·제목 그대로.
- 버튼 상태 행렬 세로 배치(좁은 화면)의 상태 이름만 12/16/400(`text-xs font-normal`, 보조색)으로 H3 "States"(14/20/500)와 구분. 공통 H2/H3 스타일은 불변.
- (이전 기록, 위 상태 매트릭스로 대체) 선택 컨트롤: `CheckboxSections`·`RadioGroupSections`·`SwitchSections`에 `gridClassName` 옵션. 통합 페이지만 공통 트랙(1000px 이상 4열, 미만은 최소 220px 자동 열 → 999px 3열·768px 2열·390px 1열)을 넘겨 모든 섹션의 예시 열이 맞는다. Radio Horizontal은 1000px 이상 2칸·미만 한 줄 전체를 써 한 줄 유지. 숨김 Checkbox(3열)·Radio Group(2/4/4열)·Switch(5열) 페이지는 그대로.
- 주의: Tailwind 임의값 class 바로 뒤에 `${…}`를 붙이면(공백 없이) 소스 스캔이 그 class를 놓친다. 이번 작업 중 Toggle Variants/States 2열이 사라졌다가 공백으로 분리해 복구.
- 검증(6026, 다른 세션 Storybook이 6025 사용 중): 버튼 1440/1400/1399/1024/768/390, 선택 컨트롤 1440/1024/1000/999/768/390 페이지·내부 가로 스크롤·잘림 0, 예시 수 불변(11/19), 상호작용·Focus·Disabled 정상, 숨김 페이지 레이아웃 불변, `tsc -p tsconfig.app.json` 통과. 인벤토리 재생성 결과 변화 없음(KEEP 73 / HIDE 315).
- 6024: 계속 `/index.json` 500(`center-spatial.stories.tsx`를 MDX 문서 단계에서 파싱한 캐시 오류). 다른 세션 서버라 재시작하지 않음 — 그 세션에서 재시작 필요.
## 2026-11-13 — Storybook 유사 컴포넌트 6개 그룹 통합·메뉴명 2건 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 방식: 2026-11-12 대화상자·팝업 메뉴 통합과 같다. 흡수되는 컴포넌트의 예제를 `src/components/<name>-examples.tsx`(Storybook 전용)로 옮기고 `<Name>Sections`(섹션 묶음, `titlePrefix`/`title`·`idPrefix` 옵션) 하나로 대표 페이지와 기존 Story가 함께 쓴다. 흡수되는 쪽의 기존 대표 Story `All`은 `verification` 태그만 붙여 사이드바에서 숨기고 같은 화면·`play`·ID·직접 URL을 유지한다. Story 파일 이동·`title` 변경·Story 삭제 없음. 패키지 컴포넌트·Props·토큰 변경 없음.
- 사이드바 결과: `버튼`(Button + "Toggle Button · …" 4섹션), `선택 컨트롤`(신규 `Components/Form/Selection Controls` → `components-form-selection-controls--all`, "Checkbox · …"·"Radio Group · …"·"Switch · States"), `시트·드로어`(Sheet + "Drawer"), `툴팁·호버 카드`(Tooltip + "Hover Card", 같은 5열), `접기·펼치기`(Accordion + "Collapsible · …"), `날짜 선택`(Date Picker + "Calendar · …"). 숨김 대표 8개(인벤토리 기준, 처음 보고의 "9개"는 오기): checkbox·radio-group·switch·toggle·drawer·hover-card·collapsible·calendar의 `--all`. 호스트 페이지 섹션 제목은 그대로, 흡수 섹션만 컴포넌트명을 붙였다(단일 섹션은 컴포넌트명만 — "Alert Dialog" 선례). 합친 페이지의 `data-overview-state`는 접두어(`checkbox-` 등)로 중복을 피한다.
- 선택 컨트롤 `play`는 Checkbox Focus 예시 하나만 focus한다(한 번에 하나만 focus 가능). Radio Group·Switch Focus는 Tab 또는 각자의 숨김 대표 Story(로드 시 focus 유지)에서 확인.
- 버튼 상태 매트릭스: 매트릭스가 자기 열보다 넓으면(390px에서 420px Standard Solid) 세 Treatment가 함께 상태별 세로 배치(상태 이름 → 버튼 줄 wrap)로 바뀐다(`MatrixFitProvider`·`data-state-matrix="table|stacked"`, ResizeObserver로 표 실제 폭과 열 폭 비교). 1400px 이상 3열·미만 1열, 버튼 크기·상태·색상은 그대로.
- 메뉴명(`manager.js`만): `components-navigation-navigation-menu` `메뉴` → `내비게이션 메뉴`, `patterns-data-data-table` `테이블` → `데이터 테이블`(2026-11-12 `테이블` 결정을 이번 요청으로 대체). 통합 라벨: `components-overlay-sheet` → `시트·드로어`, `components-overlay-tooltip` → `툴팁·호버 카드`, `components-disclosure-accordion` → `접기·펼치기`, 신규 `components-form-selection-controls` → `선택 컨트롤`. 숨김 컴포넌트 라벨은 태그 필터용으로 유지. `preview.tsx` storySort Form에 `Selection Controls` 추가.
- 문서: `docs/components/` 13개(button·toggle·checkbox·radio-group·switch·sheet·drawer·tooltip·hover-card·accordion·collapsible·calendar·date-picker) 대표 Story 행. 인벤토리 재생성: 파일 89 → 90, Story 387 → 388, PRIMARY 80 → 73, 삭제된 ID 0.
- 검증: 6024는 다른 세션 서버가 `center-spatial.stories.tsx` acorn 파싱 오류로 index 500 상태여서(파일은 HEAD와 같고 tsc 통과) `.claude/launch.json`에 `storybook-verify`(6025)를 추가해 확인. 사이드바·숨김 URL 9개·1420/1400/1399/844/580/390px 6개 페이지 페이지/내부 가로 스크롤 없음, `tsc -p tsconfig.app.json` 통과.
## 2026-11-13 — 버튼 Story: Treatment별 3열 안에 Sizes·States 통합 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 바로 아래 항목(Sizes 섹션 + Treatment별 상태 섹션)을 대체한다. `src/components/button.stories.tsx`의 `All`만 변경했고 `SizeColumns`는 `GroupBlock`으로 바꿨다. 패키지 Button·API·CSS, verification Story, 다른 Story는 변경 없다.
- 구조: `StorySectionGrid` 안에 Brand / Standard Solid / Standard Outline 공통 `StorySection`(H2 16/24/700, 제목 → 콘텐츠 12px, Border 없음)을 둔다. 각 섹션은 `GroupBlock` 두 개로 이루어진다. Sizes(H3 14/20/500 회색 → 8px → Small/Medium/Large)와 States(H3 → 8px → 기존 `StateMatrix`)이며 두 블록 사이는 24px다. 페이지 단위 Sizes / States 제목은 없다. 그룹은 섹션 가장자리에 둔다(들여쓰기 없음). 1400px 경계에서 Solid 매트릭스 420px가 1/3 열에 들어가게 하기 위해서다.
- 반응형: 미리보기(iframe) 폭 1400px 이상에서 같은 너비 3열, 미만은 1열이며 섹션 간격은 28px이다.
- 검증(6024, 미리보기 1420·1400·1399·844·580·390px): 1420에서 열 x 30 / 491 / 953, 너비 437px, 세 H2 상단 같음. 1400에서 3열(431px), 1399에서 1열로 전환. 모든 폭에서 H2 → Sizes 12px, H3 → 콘텐츠 8 / 8px, Sizes ↔ States 24px, 크기 행 높이 42px(줄바꿈 없음), 컬러(3 / 5 / 4)·상태(4)·버튼 수(12 / 20 / 16) 누락 없음, 페이지 가로 스크롤 0. 표 내부 스크롤은 390px의 Standard Solid(77px)만 있다(420px 매트릭스 > 343px 열, 버튼 크기·구조 유지 조건상 불가피). `docs/components/button.md` 갱신, `tsc -b` 통과.
## 2026-11-13 — 버튼 Story: Variants 제거·Sizes와 상태 비교를 Brand / Standard Solid / Standard Outline 3열로 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `src/components/button.stories.tsx`의 대표 Story `All`만 변경했다. Variants 섹션과 그 전용 `GroupRows`, 비례 폭 `GroupColumns`를 제거했다. 패키지 Button, API, 스타일, verification Story(Roles·Sizes·Brand States·Standard States 등)는 변경 없다.
- Sizes: `SizeColumns`로 Brand / Standard Solid / Standard Outline(Outline Small/Medium/Large 추가, 기존 `size` API 지원) 같은 너비 3열을 미리보기 840px 이상에서, 미만은 1열로 둔다. 그룹명 H3(14/20/500 회색) → 버튼 8px, 버튼 간격 8px. 상태 열과 시작 위치를 맞추려고 그룹은 섹션 가장자리에 둔다(`data-sb-board` 들여쓰기 미사용).
- 상태 비교: "States" 제목을 지우고 `StorySectionGrid` 안에 Treatment별 공통 `StorySection`(제목 16/24/700, 제목 → 매트릭스 12px)을 둔다. 미리보기 1400px 이상에서 같은 너비 3열(Solid 매트릭스 420px 기준), 미만은 Brand → Solid → Outline 1열이다. 매트릭스 구조(행 Default/Hover/Pressed/Disabled × 색상), 버튼 수(12 / 20 / 16)는 그대로다.
- 검증(6024, 미리보기 1420·844·580·390px = 화면 1600·1024·760·390): 1600에서 Sizes 열과 상태 열이 x 30 / 491 / 953, 너비 437px로 일치하고 매트릭스 내부 스크롤 0이다. 1024에서는 Sizes 3열(열 240px, 행 높이 42px로 줄바꿈 없음)·상태 1열, 760에서는 둘 다 1열이며 내부 스크롤 0이다. 페이지 가로 스크롤은 모든 폭에서 0이다. 미해결: 390px에서 Standard Solid 매트릭스(420px)가 열(343px)보다 넓어 표 영역 안에서만 77px 가로 스크롤된다(기존 동작, 버튼 크기·구조 유지 조건상 불가피). `docs/components/button.md` Story 구성 설명을 갱신했다. `tsc -b` 통과. `.storybook/qa` 인벤토리는 재생성하지 않았다.
## 2026-11-13 — 조직도 › 개요 첫 섹션 제목 → 콘텐츠 25 → 12px (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 원인: Storybook 미리보기 `src/org-chart/core-component-preview.tsx`가 만든 Shadow DOM에서 `hideLogoHeading`으로 패키지 로고 범례의 `h2`를 지우면, `ul.hm-orgchart-logo-list`의 브라우저 기본 `margin-top: 1em`(13px)이 섹션 제목 바로 아래 남았다. 패키지 CSS에는 이 margin 규칙이 없고, 패키지에는 공식 확장 지점(`::part`·slot·추가 스타일시트 옵션)이 없다.
- 수정: 같은 `hideLogoHeading` 분기에서 남은 로고 목록의 `style.marginTop = '0'`만 지정했다. 사용처는 `org-chart-page-previews.tsx:44`(개요 첫 섹션) 하나다. 패키지 CSS·API·토큰·다른 Story는 변경 없다. 아래쪽 기본 margin 13px은 다음 섹션의 28px과 상쇄되어 보이지 않으므로 그대로 둔다.
- 검증(6024, 1600·1024·760·390px): 개요 세 섹션 모두 제목 → 콘텐츠 12px, 섹션 사이 28px이다. 로고 7개, 목록 gap 16px, 열 수 7 / 4 / 2 / 1, 가로 스크롤 0. `tsc -b` 통과. 바로 아래 항목의 보류 B는 해결되었다.
## 2026-11-13 — Storybook 메뉴별 공통 스타일 정합성 점검·A/C 수정 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 점검: 사이드바에 보이는 80개 Story(verification·internal 태그 제외)를 미리보기 폭 1420·390px에서 측정했다. 모두 공통 틀(`story-page.css`)을 쓰고, 77개는 좌우 30/16px·제목 → 콘텐츠 12px·섹션 사이 28px·제목 Border 0으로 일치한다. 공통 섹션이 없는 단일 미리보기 Story(SW Overview·속성창 비교·Viewer 콘텐츠 변형·확장 조직도·연계 시설물)는 고유 구조로 유지했다.
- A. SW › 뷰어 › 화면 너비: 숨김 콜백 기록 `<p hidden>`이 H1과 첫 블록 사이에 있어 공통 첫 블록 reset이 빠지고, 스택의 28px margin-top이 남았다(PC 52px / 모바일 44px 시작). `src/sw/sw-stories.css`에 `.sb-page > .sb-page-inner > .sb-page-a11y-title:first-child + [hidden] + .sw-story-stack { margin-top: 0; }`만 추가했다(공통 CSS 미변경). 이 구조는 이 Story에만 있다. 다른 Story는 모두 이미 시작 위치가 같았다. 결과: 1600·1024·760·390px에서 시작 24/17/16/16px(공통 gutter `clamp(16px, 2vw, 24px)`와 일치), 제목 → 콘텐츠 12px.
- C. 노선도 › 연계 시설물: `.sb-center--plain .linked-facility__group` gap 16 → 0. 도로 등급 제목 → 카드 격자 28 → 12px(헤더 padding 12px). 카드 들여쓰기 16px, 그룹 사이 28px, 열 수(1600 3열·1024 2열·760/390 1열), 그래픽은 유지된다. 가로 스크롤·넘침 0.
- 보류 B. 조직도 › 개요 › 관계사·회사 로고: 제목 → 콘텐츠 25px(+13px). 원인은 `@hm-design/org-chart`의 Shadow DOM 안 `.hm-orgchart-logo-list`(`packages/org-chart/src/styles/org-chart-content.css`)에 남은 브라우저 기본 `ul` margin(1em)이다. 패키지 CSS라 소비 프로젝트 영향을 검토한 뒤 결정하기로 하고 수정하지 않았다.
- 기존 문제(패키지 컴포넌트 최소 폭, 이번 변경과 무관): SW 하단 명령어바 전체·화면 너비 Story는 390px에서 가로 넘침 203/218px(`.hm-sw-footer__command` min-width 280px). SW Viewer 로고·화면 너비 Story는 390px에서 2px(`.hm-sw-viewer-header__actions`).
- `tsc -b` 통과.
## 2026-11-13 — 공통 섹션 제목 하단 Margin 0 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `src/components/story-page.css`의 `.sb-page-section-header`: `margin-bottom` 16px → 0. `padding-bottom` 12px은 유지하고 Border는 없다. 그래서 섹션 제목(설명이 있으면 설명)에서 콘텐츠까지 28px → 12px이다. 4px 안은 사용자가 취소했다.
- `src/foundation/foundation.css`: 바로 아래 항목에서 넣은 Font Family·Font Weight 전용 `margin-bottom: 10px` 예외를 지워 공통 기준(12px)을 따르게 했다. Specimens 그룹 간격(H3 → 첫 구분선 8px, 그룹 사이 24px)과 행 좌우 12px은 그대로다.
- CENTER의 `.sb-center … > header`는 자체 margin 0·padding을 쓰는 Story 고유 구조라 변경하지 않았다. 다른 Story CSS에는 섹션 헤더 margin 재정의가 없다.
- 검증(6024, 미리보기 폭 1420·844·580·390px): 한맥 컬러·가족사 컬러·폰트·Button·Input·데이터 테이블은 모든 섹션 12px, 막대 차트는 11~12px이다. SW Header Responsive More도 헤더 하단과 본문 시작이 일치해 12px이다. 가로 스크롤 0. 예외로 SW Footer 전체 Story는 390px에서 기존 Footer 명령 영역(280px) 때문에 가로 넘침이 있다. 세로 Margin 변경과 무관하다. `tsc -b` 통과.
## 2026-11-13 — Storybook 콘텐츠 좌우 여백 60 → 30px·SW 기준점 재계산·폰트 섹션 상단 간격 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `src/components/story-page.css`의 `.sb-page-inner`: 좌우 여백(각각)을 `clamp(16px, 16px + (100vw − 480px) × 11/72, 60px)`에서 `clamp(16px, 16px + (100vw − 480px) × 7/144, 30px)`로 바꿨다. 최대값만 절반이고 모바일 16px은 유지한다. 실측(미리보기 폭 기준)은 1420·844px에서 60 → 30px, 580px에서 약 21px, 390px에서 16px이다. LNB(180px)·manager CSS는 변경하지 않았다.
- SW Story 전용 기준점(Inner 좌우 60px 전제였음을 주석과 계산으로 확인): `sw-stories.css`의 1920 행 container 2039.98 → 1979.98px(1920 + 60), 속성창 비교 3열 1125 → 1065px(990 + 60 + 15), 1열 전환 786.98 → 726.98px(652 + 약 28 × 2 + 15 = 723). `sw-footer-stories.css`의 같은 1920 container 조건도 1979.98px. 관련 주석을 갱신했다. 패키지 SW 컴포넌트는 변경하지 않았다.
- `src/foundation/foundation.css`: Font Family·Font Weight 섹션 제목 아래 여백 16 → 10px(`.sb-page.hm-foundation .hm-typo-type-grid .sb-page-section-header`)으로, 제목에서 첫 행까지 28 → 22px이다. Specimens는 독립 열 유지가 확정되었다. 이미 그룹 제목 → 첫 구분선 8px, 그룹 사이 24px로 통일되어 있어 수정하지 않았다.
- 검증(6024): 폰트 Story를 1600·1024·760·390px에서 확인했다. 제목 → 첫 행 22/22px, 그룹 8/24px, 행 padding 12px, 구분선 유지, 섹션 Border 0, 가로 스크롤 0, 넘침은 화면 읽기용 H1뿐이다. 한맥 컬러·Button·Input·막대 차트·데이터 테이블의 4개 폭에서 여백 30/30/21/16px, 가로 스크롤 0, 렌더 오류 0이다(Button의 `sr-only` 3개는 기존). SW 속성창 비교는 1064px 2열 → 1065px 3열, 726px 1열 → 727px 2열이며 넘침 0이다. Header Responsive More는 1979px 숨김 → 1980px 1920 행 표시. Footer·Viewer는 세로 스크롤바 15px 때문에 1995px에서 표시(1990px 숨김)되며 이는 기존 container 기준 그대로다. Step Header Responsive는 1995px 표시, 가로 스크롤 0이다. `tsc -b` 통과.
## 2026-11-13 — Foundation 폰트 목록 행 좌우 12px 안쪽 여백 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `src/foundation/foundation.css`에 `.hm-foundation .hm-typo-row { padding-inline: 12px; }` 한 줄만 추가했다. 이 클래스는 폰트 Story에서만 쓴다(Font Family·Font Weight·Specimens 모든 행).
- 행 구분선은 각 행의 `border-top`이라 시작점과 너비가 그대로이고, 안쪽 텍스트만 12px 들어간다. 섹션 제목·그룹 H3 위치, 세로 간격, 폰트·토큰 값, 컬럼 규칙(`min(240px, 40%)` / `1fr`, 440px container 전환)은 유지한다. 공통 섹션 제목 Border는 다시 넣지 않았다.
- 검증(6024, 미리보기 폭 1420·844·580·390px = 화면 1600·1024·760·390px): 14개 행 모두 padding 12px, 행 시작 x와 그룹 H3 x가 같고 텍스트는 +12px이다. 섹션 제목 위치는 변화 없다. 자식 요소가 좌우 12px 안쪽을 벗어나는 경우 0, 잘림 0, 가로 스크롤 0. 390px에서는 기존대로 1열(미리보기 → 정보)이다. `tsc -b` 통과.
## 2026-11-13 — Storybook LNB 조밀화·섹션 제목 구분선 제거·Default 컬러 구분 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 모두 Storybook 표시 전용이며 패키지 CSS·토큰·Story 파일은 바꾸지 않았다. 신규 토큰도 없다.
- `.storybook/manager.css`(180px 매니저 사이드바): 좌우 여백 20 → 14px, 1뎁스·섹션 행 48 → 40px, 2뎁스 이상 42 → 36px, line-height 28 → 24px, 행 패딩 `8px 14px 8px 10px`(우측 32 → 14px), 아이콘·텍스트 간격 12 → 8px, 들여쓰기 32/44 → 26/36px(1뎁스 leaf 26px), 섹션 구분 간격 24 → 16px. 선택 상태 Radius 8 → 6px, 연결 곡선 `--hm-manager-join` 18 → 12px, 그룹 시작·끝 Radius·하단 여백 6px. 1뎁스 라벨은 chevron 유무와 관계없이 x=40에 정렬된다.
- `managerHead`가 서버 시작 시 `manager.css`를 읽으므로 6024를 재시작해야 반영된다. 이번 검증은 현재 파일 내용을 `#hm-manager-style`에 주입해 확인했다.
- `src/components/story-page.css`: 모든 Story 공통 섹션 제목(`.sb-page-section-header`)의 하단 구분선만 제거했다. 12px + 16px 간격은 유지한다. `src/center/center-stories.css`의 연계 시설물 그룹 제목(섹션 제목 모양)도 같은 기준으로 구분선을 제거했다.
- `src/foundation/foundation.css`: 한맥 컬러의 Default 칩 좌우에 2px 페이지 배경색(`--hm-sem-bg-content`) 경계를 바깥 그림자로 겹쳐 그린다. 칩 너비(1024px에서 모두 177px)·값·순서·텍스트는 같고, 줄 시작에서는 경계가 페이지 배경에 묻힌다. 가족사 컬러에는 Default가 없어 영향이 없다.
- 검증(6024): 1600(8칸)·1024(4칸)·760(4칸)·390(2칸)px 컬러 화면, 매니저 선택·펼침 그룹·Hover, 390px 모바일 메뉴 행 40px, Input Story의 섹션 제목 확인. `tsc -b` 통과.
- 후속 검증(6024 재기동 후): 6024는 이미 내려가 있어 `.claude/launch.json`의 `storybook` 구성으로 새로 띄웠다. 주입 없이 `#hm-manager-style`에 새 CSS가 들어갔다(join 12px, 콘텐츠 여백 `20px 0 20px 14px`). 행 높이는 섹션·1뎁스 40px, 2뎁스 36px, 그룹 끝 42px(36 + 하단 6px)이고 라벨 x는 섹션 36·1뎁스 40·2뎁스 50이다. 선택(입력창)은 36px, Radius `6px 0 0 6px`, 곡선 12px, 배경 `#F5F0E5`이다. Hover는 Black 10%이고, 내비게이션 그룹 펼침(시작 Radius 6px·끝 6px)과 접힘도 정상이다. CENTER `노선도/시설물 › 연계 시설물`(`--plain`)의 그룹 제목 5개는 하단 Border 0, padding 12px 유지, 가로 스크롤 0이다. 공통 섹션 제목을 쓰는 한맥 컬러·가족사 컬러·폰트·입력창·막대 차트·데이터 테이블은 Border 0이고, 제목 → 본문 28px(차트는 설명 포함 52px, 기존 구성)이며 렌더 오류와 가로 스크롤이 없다. SW Header·Footer Overview는 공통 섹션 제목을 쓰지 않는다. 추가 수정은 없다.
## 2026-11-12 — 노선도 배치·간격을 공통 Storybook 기준으로 정렬 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 기준: Button·Badge 등이 쓰는 `story-page.css`(H2 → 12px + 구분선 → 16px, 섹션 28px, 들여쓰기 16px / 640px 이하 12px)와 `story-board.tsx`(H3 14/20/500 회색, 설명 14px 회색 4px 아래, 그래픽 12px 아래, 행·열 24px).
- 변경은 `src/center/center-stories.css`의 `.sb-center.sb-center--plain` 규칙에만 있다. 바로 아래 항목의 카드 제거 블록 뒤에 이어 붙였다. 조직도(`.sb-center`만 사용)·Overview(`plain` 아님)·공통 `story-page.css`·패키지 CSS·Story 파일은 변경 없음.
- 적용: `.sb-center` 위아래 8px과 연계 시설물 18px 여백 제거. `개요` 본문 안의 노선도 블록 16px(12px) 들여쓰기. 예제 제목은 500 회색, 설명은 12px → 14px. 공간 화면의 `h4`·`csi-legend`·`csi-note`도 같은 기준이며, 설명은 `-8px` 위쪽 여백으로 제목 아래 4px에 맞춘다. 설명 → 그래픽 16px → 12px. 예제 간격 32px → 24px.
- 정렬: 그래픽을 가운데에서 제목과 같은 왼쪽 기준선으로 옮겼다. 데모 stage의 `min-height`(터널·시설물 154px, 사업명 184px, 연계 변형 122px)는 카드 시절 빈 프레임 안에서 그래픽을 가운데에 두던 값이라, plain 화면에서만 0으로 두어 그래픽 높이를 따른다. 그래픽 자체의 크기·색·구성, 로컬 가로 스크롤, 760px 이하 1열은 그대로다. `tunnel/facility-reference__row`의 `space-evenly`는 `flex-start`로 바꿨다.
- 연계 시설물: 도로 등급 그룹 제목은 섹션 제목 모양(16/24/700, 12px + 구분선, 그룹 28px 간격, 헤더 → 격자 16px). 카드 격자는 16px(12px) 들여쓰기, `minmax(min(100%, 320px), 1fr)`를 써서 760px에서도 1열이다. 배율 안내·맥락 띠는 섹션 앞 블록이라 들여쓰지 않는다.
- 검증(6024): 보이는 노선도 Story 10개와 숨김 Overview를 1024·760·390px에서 확인했다. 페이지 가로 스크롤 0, 넘친 텍스트 0, 콘텐츠 시작 x는 Button과 같다(76 / 75 / 28). 정보·사업명·공간·터널 계열의 그래픽 내부 로컬 스크롤은 변경 전과 같은 기존 동작이다. 조직도 Expanded·Department Colors에는 `--plain`이 없다. `npm run typecheck` 통과.
## 2026-11-12 — 노선도 Story 샘플 카드 박스 제거 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 대상: 노선도 Story 5개(시설물·노선·공간·사업명·정보·연계 시설물). 샘플 카드 프레임(배경·테두리·Radius), 카드 머리 띠(배경·하단 테두리), 연계 시설물 위치도/상세도 사이 가로선을 없앴다. 제목·설명 텍스트와 그래픽(시설물 SVG, 정보 카드, 사업명 박스, 노선명 배지, 범위 카드, 공간 단면 `#efefef` 캔버스)은 유지한다.
- 방식: `src/center/center-shared.tsx`의 `CenterFrame`에 `plain` 옵션을 두고 `withRepresentation`만 `sb-center--plain`을 붙인다. `src/center/center-stories.css`의 새 규칙은 모두 `.sb-center.sb-center--plain` 아래에만 있다. 조직도 Department Colors·Expanded가 `.sb-center`·`.tunnel-reference__sample`·`--sb-center-card-*`를 재사용하므로 기존 공통 카드 규칙과 변수는 그대로 둔다. Overview(검증용)는 `CenterFrame`을 직접 써서 기존 카드가 유지된다.
- 간격: 항목 32px, 2열·연계 카드 격자 24px, 제목↔설명 4px, 그래픽 위 16px(좌우·아래 0). 가운데 정렬·로컬 가로 스크롤·760px 이하 1열 전환은 기존 규칙 그대로. 패키지(`packages/center-graphic`) 변경 없음.
## 2026-11-12 — 사이드바 표시명 `팝업 메뉴`·`테이블` (브랜치 `style/storybook-ui-refinements`, 미커밋)
- `components-overlay-dropdown-menu` 표시명 `드롭다운·컨텍스트 메뉴` → `팝업 메뉴`(180px 사이드바 1줄). 화면 제목 "Dropdown Menu · Context Menu"·섹션·Story ID·정렬은 불변. 같은 이름을 쓰던 `docs/components/dropdown-menu.md`·`context-menu.md`와 Story 주석 3곳도 맞춤.
- `patterns-data-data-table` 표시명 `데이터 테이블` → `테이블`(manager.js만). `컴포넌트 > 데이터 표시 > 테이블`(Table 컴포넌트)과 그룹은 다르지만 같은 이름이다(사용자 결정).
- 인벤토리는 Story 이름이 아니라 manager 라벨만 바뀌어 재생성 불필요. manager.js 변경은 Storybook dev 서버 재시작 후 반영.
## 2026-11-12 — Storybook 대화상자·드롭다운/컨텍스트 메뉴 사이드바 통합, 데이터 테이블 메뉴명 (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 방식: Story 파일 4개(`src/components/dialog`·`alert-dialog`·`dropdown-menu`·`context-menu.stories.tsx`)와 모든 Story ID 유지. 통합되는 쪽의 기존 대표 Story `All`(`components-overlay-alert-dialog--all`, `components-overlay-context-menu--all`)에 `verification` 태그만 붙여 사이드바에서 숨긴다(URL 직접 접근·검증 Story·`play` 유지).
- 공용 예제(Storybook 전용, 중복 구현 없음): `src/components/alert-dialog-examples.tsx`(`AlertDialogConfirmation`, `AlertDialogDestructive`), `src/components/context-menu-examples.tsx`(`ContextMenuFull`). 원래 Story 파일에서 옮겼고 두 Story 파일이 함께 import한다.
- 대화상자: Dialog 대표 화면에 "Alert Dialog" 섹션(로그아웃·삭제 트리거) 추가 → 트리거 8개. 사이드바 `대화상자` 하나.
- 드롭다운·컨텍스트 메뉴: Dropdown Menu 대표 화면 제목 "Dropdown Menu · Context Menu", 섹션 "Dropdown Menu"(트리거)·"Context Menu"(우클릭 영역). 사이드바 표시명 `드롭다운·컨텍스트 메뉴`(Navigation Menu의 `메뉴`와 구분, 180px 사이드바에서 2줄; 이후 `팝업 메뉴`로 변경, 위 항목). `manager.js`의 `components-overlay-alert-dialog`·`components-overlay-context-menu` 라벨은 태그 필터로 숨김 Story를 볼 때를 위해 유지.
- `patterns-data-data-table` 표시명 `테이블 패턴` → `데이터 테이블`(manager.js만).
- 문서: `docs/components/dialog.md`·`alert-dialog.md`·`dropdown-menu.md`·`context-menu.md`의 대표 Story 표와 §9. 인벤토리는 `node .storybook/qa/generate-inventory.mjs`로 재생성(PRIMARY 82 → 80, Story 수 387 불변).
- 검증 메모: 브라우저 창이 화면에 그려지지 않을 때 닫힘 animation이 늦게 끝나 overlay가 1초 이상 DOM에 남는다. 닫힘 판정은 `data-state`나 충분한 대기 후 DOM 제거로 확인한다.
## 2026-11-12 — Storybook 메뉴명 6건·사이드바/LNB 1뎁스 왼쪽 Padding 12px (브랜치 `style/storybook-ui-refinements`, 미커밋)
- 메뉴명(사이드바 표시명만, Story ID·export·title·본문 제목·정렬 순서 불변): `.storybook/manager.js` 라벨 4건(내비게이션 메뉴 → 메뉴, 데이터 테이블 → 테이블 패턴, 전체 메뉴 (GNB) → 상단 메뉴, 좌측 메뉴 (LNB) → 좌측 메뉴), Story `name` 2건(`sw-footer--full-footer` SW 하단 명령어바 → SW 명령어바, `center-graphic-facility--roadside` 휴게소 · 졸음쉼터 → 연계 노선). SW 정렬은 storySort가 Story 이름을 쓰므로 `.storybook/preview.tsx`의 `["SW 명령어바", "*"]`도 함께 변경. 본문 `StoryPageHeader` 제목은 그대로(Roadside는 `page()` 첫 인자, Footer는 별도 literal).
- Storybook 사이드바: `.storybook/manager.css` 기존 규칙 `#storybook-explorer-tree .sidebar-item > :is(a[id], button[id]:not([data-testid]))`의 padding을 `10px 32px 10px 12px`로(이전 왼쪽 18). 더 구체적인 기존 규칙은 유지: 2뎁스 이상 padding-block 7px, 하위 없는 1뎁스 leaf 왼쪽 32px.
- LNB: `--hm-lnb-item-padding-start` 18 → 12px(`packages/ui/src/theme/lnb.css`, 기준값 `src/theme/verification-baseline.json`, 문서 `docs/patterns/lnb.md`). 1뎁스 행 padding `10 32 10 12`(오른쪽 = inset-end 20 + 12). 2뎁스 글자 x 88·중첩 그룹 trigger 58·3뎁스 108은 불변. 2뎁스 링크 배경 시작(`--hm-lnb-subitem-surface-inset`)은 이 토큰으로 계산되어 x 68 → 62(1뎁스 그룹 글자 시작과 정렬 유지).
- Storybook은 `@hm-design/ui`를 `packages/ui/dist`로 읽으므로 LNB 반영에 `npm run build:ui`가 필요하다(dist는 gitignore). 소비 프로젝트는 dist 재빌드 + Submodule 갱신 후 반영.
- manager.js/manager.css 변경은 Storybook dev 서버 재시작 후 반영된다. 인벤토리는 `node .storybook/qa/generate-inventory.mjs`로 재생성(차이는 이름 2건뿐).
## 2026-11-12 — Foundation 한맥·가족사 컬러칩, 폰트 미니멀 타이포그래피 레이아웃 (브랜치 `style/storybook-ui-refinements`)
- 범위: `src/foundation/`(`color.stories.tsx`·`company-colors.stories.tsx`·`typography.stories.tsx`·`reference.tsx`·`foundation.css`)과 `docs/guidelines/brand-color-steps.md`. 토큰·HEX·`themes/*.json`·컴포넌트·패키지 공개 API·`reference-data.ts`·Story ID(`foundation-color--all`·`foundation-company-colors--all`·`foundation-typography--all`) 불변. 폰트 섹션 순서(Text Colors → Font Family → Font Weight → Typography Specimens) 불변.
+7
View File
@@ -51,6 +51,11 @@ CENTER 도메인(제목 · 정보 표기 · 노선/공간 · 시설물)의 현
- 시설물 총 너비(기준 이름): 터널 위치도 210 · 실사화 220 (Source reference total과 일치).
- 노선: 중심선 18u, 윤곽 중심선의 20%, 그림자 층 순서 Shadow → Outline → Primary Stroke(`CONFIRMED`).
- (2026-11-13, Storybook 데모 설명에서 옮김 · `CenterSpatialInformation.css`/`.tsx`와 대조) 윤곽 3.6u. 그림자 X0 Y0 Blur 6u, Opacity 70%. 윤곽색은 지방도 · 구도·면도·이도가 Dark Outline(#000 / 30%), 나머지 분류는 White Outline. 상태 표현: 공사중 = 중심선에 흰 블록 32u / 간격 32u, 계획 = 45° 흰 빗금(+ 흰 워시).
- 사업 노선: 중심선 #D21203, 끝 원형 표시 = 선 두께의 150%(18u 선에서 27u), 노선과 같은 윤곽·그림자.
- 노선명 배지: 높이 54u, 좌우 padding 9u + 텍스트 너비. 노선번호 기호: 기본 64u. 방면 배지: 높이 50u의 완전히 둥근 캡슐, 흰색 80% 안쪽 윤곽 2u, 7점 화살표 다각형, 콘텐츠 기반 너비.
- 정보(`CenterInformationDisplay.css`와 대조): 테이블형 행 높이 = 기본값 + 추가 행마다 26.116u + 추가 항목마다 34.364u(줄 높이 26.116u + 항목 간격 8.248u). 강조형 1의 패널은 카드 뒤로 4u 겹쳐 배치된다.
- 휴게소·졸음쉼터 실사화 분리형: 예제 간격 19 / 14(`ROADSIDE_ACTUAL_CONFIG.gaps`). 데모 설명에 있던 조각 구조 13 / 90 / 13은 코드에서 확인하지 못한 Source 표기 값이며 확정값이 아니다.
- 글꼴: Noto Sans KR(Canonical). 폰트 파일 배포와 fallback 순서는 `PLATFORM_MAPPING`/`TBD`이며 이 패키지는 폰트를 포함하지 않는다.
- 상태: 노선만 상태(기본/공사중/계획)를 가진다. Facility는 정적이며 Selected/Hover가 없다.
@@ -75,6 +80,8 @@ import { CenterTitleReferenceDemo } from "@hm-design/center-graphic/reference"
`Center Graphic` 그룹(기본 sidebar): `Title`, `Information`, `Spatial`, `Facility`(터널 · 교량 · 교차로 · 분기점 · 나들목 · 휴게소·졸음쉼터 · 연계 시설물), verification 숨김: `Overview`(배율 규칙·상태 표, 직접 URL `center-graphic-overview--overview` 유지), Title `Playground`, Facility `직접 테스트 (터널)`. 제작 대상 해상도는 주소 `?centerResolution=3840x2160`으로 지정한다.
공통 페이지 레이아웃(2026-11-13, `src/center/center-stories.css`의 `.sb-center--plain` 범위): 별도 `개요` 섹션 제목 없이 예제를 한 번 들여쓰고(`sb-page-indent`), 예제 제목 16/24/700 · 설명(또는 사이즈) 12px 회색을 8px 간격의 한 행에 둔다(공간 부족 시 줄바꿈). 제목 → 그래픽 12px, 예제 사이 24px. 표시 배율 안내는 콘텐츠 오른쪽 끝 정렬이며, 사업명 페이지만 첫 예제 제목 행 오른쪽에 둔다. 데모 설명은 구성명·사용 기준·비교에 필요한 짧은 정보와 상태 표기(`SOURCE_RECEIVED`/`SOURCE_PENDING`, `KNOWN_DEVIATION`, `READY`, 너비 테스트 라벨)만 두고, 제작 수치·SVG 로딩·Source 작성 방식 설명은 화면에서 뺐다(필요한 수치는 §4, 2026-11-13). 예외: 연계 시설물 그룹·카드 구성, 노선·공간의 제목 아래 긴 설명.
## 8. 미확정 / 보류 (원본 기준)
center-domain.md §5 registry를 따른다: 표시 배율 Q2b·Q3 `HOLD`, Facility §12.24 `OWNER_REVIEW`, Information 일부 `TBD`/`Candidate`, Title Resolution B `TBD`, 전체 layer order·collision `TBD`, 철도 윤곽 두께·line join `TBD`, `symbol.md` 정확한 기하·팔레트 `TBD`, 폰트 binary 승인 `TBD`. Storybook `Overview`에 상태 표가 있다. 이 구현은 위 항목을 확정하지 않는다.
@@ -353,7 +353,6 @@ export function CenterInformationDisplayReferenceDemo() {
<div className="cid-demo">
<Block
title="박스형 · 정의 목록 (박스형 설명1)"
note="배지 + 그라데이션 레이블 칩 + 값 블록; 하위 불릿은 값 열 유지"
>
<CenterInformationBox
layout="definition"
@@ -370,7 +369,7 @@ export function CenterInformationDisplayReferenceDemo() {
/>
</Block>
<Block title="박스형 · 목록 — Source 팔레트 4종" note="지오메트리 동일; 팔레트만 다름">
<Block title="박스형 · 목록 — 팔레트 4종" note="지오메트리 동일; 팔레트만 다름">
{['navy', 'teal', 'orange', 'violet'].map((palette) => (
<CenterInformationBox
key={palette}
@@ -382,7 +381,7 @@ export function CenterInformationDisplayReferenceDemo() {
))}
</Block>
<Block title="테이블형" note="행 높이 = 기본값 + 추가 행마다 26.116 u + 추가 항목마다 34.364 u">
<Block title="테이블형">
<CenterInformationTable
title="사업효과"
width="643px"
@@ -401,7 +400,7 @@ export function CenterInformationDisplayReferenceDemo() {
/>
</Block>
<Block title="강조형 · 화살표 표시 (강조형 1)" note="패널은 카드 뒤에 4 u 겹침으로 배치되며 투명하게 사라짐">
<Block title="강조형 · 화살표 표시 (강조형 1)">
<CenterInformationEmphasis
title="사업목적"
arrow
@@ -429,7 +428,7 @@ export function CenterInformationDisplayReferenceDemo() {
/>
</Block>
<Block title="동적 콘텐츠 — 짧은 / 기준 / 긴 / 매우 긴" note="소유 단일행은 말줄임으로 클리핑; 명시적 줄바꿈 유지; 장문이 지오메트리를 늘리지 않음">
<Block title="동적 콘텐츠 — 짧은 / 기준 / 긴 / 매우 긴" note="한 줄 항목은 말줄임, 명시적 줄바꿈 유지">
{CONTENT_STATES.map((state) => (
<div className="cid-demo__state" key={state.key} data-content-test={state.key}>
<span className="cid-demo__tag">{state.label}</span>
@@ -452,11 +452,6 @@ export function CenterSpatialInformationReferenceDemo() {
<div className="csi csi-demo">
<Panel
id="csi-area-route" title="1. 노선 — 노선, 상태, 윤곽"
legend={
"중심선 18u, 윤곽은 중심선의 20% (3.6u), 그림자 X0 Y0 Blur 6u Opacity 70%. "
+ "지방도 · 구도 · 면도 · 이도는 Dark Outline (#000 / 30%), 나머지 분류는 White Outline 적용. "
+ "Source SVG를 로드하지 않음: 외피는 spread box-shadow, 공사중은 반복 파선 그라데이션, 계획은 45° 빗금 그라데이션."
}
note={
"KNOWN_DEVIATION — 구도,면도, 이도.svg는 흰색 윤곽으로 내보내며 명시된 Source 규칙과 시안 색상 견본에 모순"
+ "여기서는 명시된 규칙 구현"
@@ -479,7 +474,6 @@ export function CenterSpatialInformationReferenceDemo() {
<Panel
id="csi-area-project-route" title="1b. 사업 노선 — 사업노선과 끝 표시"
legend={"사업 노선 #D21203. 원형 표시 = 선의 150%인 원 (18u 선에서 27u), 노선 윤곽과 그림자 공유."}
>
<div className="csi-scale-50" style={{ padding: '10px 4px 4px' }}>
<ProjectRoute />
@@ -490,8 +484,6 @@ export function CenterSpatialInformationReferenceDemo() {
id="csi-area-route-information" title="2. 노선 정보 표기 — 노선번호와 노선명 (동적 텍스트)"
legend={
'노선 식별에 필요한 경우 노선번호 또는 노선명을 선택하여 표기한다.'
+ "아래 문자열은 모두 실제 DOM 텍스트다. Source SVG에는 윤곽 글리프 경로로 기록되어 있지만 해당 지오메트리는 런타임에서 사용하지 않음. "
+ "노선명 너비 = padding 9u + 고유 텍스트 너비 (높이 54u 고정). 노선번호 기호는 배지 기본 64u로 고정."
}
note={
"KNOWN_DEVIATION — 고속도로 노선명.svg 높이는 48u이며 고속도로/특별시도 이름 표면 (#2E619A / #9729E5)은 선 색상과 다름"
@@ -531,7 +523,6 @@ export function CenterSpatialInformationReferenceDemo() {
id="csi-area-direction" title="3. 방면 표기 — 방면 배지"
legend={
'노선의 진행 방향을 안내할 경우 화살표와 방면명을 조합한 배지형으로 표기한다. 노선 유형과 관계없이 동일한 형태로 적용한다.'
+ "완전히 둥근 50u 캡슐, 흰색 80%의 안쪽 윤곽 2u, 7점 화살표 다각형, 콘텐츠 기반 너비."
}
>
<div className="csi-scale-50" style={{ position: 'relative', padding: '4px 0 18px' }}>
@@ -422,8 +422,8 @@ function FacilityReferenceDemo({ config, alternateName }: { config: FacilityConf
return (
<div className="facility-reference" data-reference-facility={config.id}>
<div className="facility-reference__variant-grid">
<Sample title="Position Map" note="로컬 SVG · 위치도"><LocalAssetFacility config={config} /></Sample>
<Sample title="Actual / Detail" note="로컬 SVG · 실사화"><LocalAssetFacility config={config} variant="actualDetail" /></Sample>
<Sample title="Position Map" note="위치도"><LocalAssetFacility config={config} /></Sample>
<Sample title="Actual / Detail" note="실사화"><LocalAssetFacility config={config} variant="actualDetail" /></Sample>
</div>
{alternateName ? (
@@ -435,14 +435,14 @@ function FacilityReferenceDemo({ config, alternateName }: { config: FacilityConf
</Sample>
) : null}
<Sample title="Separated" note="Source 작성 부분 간격">
<Sample title="Separated">
<div className="facility-reference__row">
<LocalAssetFacility config={config} separated />
<LocalAssetFacility config={config} variant="actualDetail" separated />
</div>
</Sample>
<Sample title="너비 동작" note="Source 정의 가변 영역만 확장">
<Sample title="너비 동작" note="가변 영역만 확장">
<div className="facility-reference__width-tests">
{widthNames.map((name, index) => (
<div key={name}><span>{['SHORT', 'MEDIUM', 'LONG'][index]}</span><LocalAssetFacility config={config} name={name} /></div>
@@ -473,7 +473,7 @@ export function RoadsideReferenceDemo() {
return (
<div className="facility-reference roadside-reference" data-reference-facility="roadside">
<Sample title="Position Map" note="재구성 Primitive · 콘텐츠 기반 너비">
<Sample title="Position Map" note="콘텐츠 기반 너비">
<div className="roadside-tag-tests">
{tests.map((test) => (
<div key={test.size} className="roadside-tag-test" data-roadside-test={test.size.toLowerCase()}>
@@ -484,7 +484,7 @@ export function RoadsideReferenceDemo() {
</div>
</Sample>
<Sample title="Actual / Detail" note="Source 작성 그라데이션/filter 그래픽 · 고정 양 끝 + 가변 중앙">
<Sample title="Actual / Detail" note="고정 양 끝 + 가변 중앙">
<div className="roadside-tag-tests">
{tests.map((test) => (
<div key={test.size} className="roadside-tag-test" data-roadside-actual-test={test.size.toLowerCase()}>
@@ -495,11 +495,11 @@ export function RoadsideReferenceDemo() {
</div>
</Sample>
<Sample title="Actual / Detail · Separated" note="Source 작성 13 / 90 / 13 구조, 예제 간격 19 / 14">
<Sample title="Actual / Detail · Separated">
<RoadsideFacilityTag entity="drowsy-shelter" variant="actualDetail" separated />
</Sample>
<Sample title="연계 / 노선 맥락" note="노선 + 앵커 + 연결선 + 같은 시설물 태그 계약">
<Sample title="연계 / 노선 맥락" note="노선 + 앵커 + 연결선 + 시설물 태그">
<RoadsideAppliedExample />
</Sample>
</div>
@@ -178,15 +178,15 @@ export function TunnelReferenceDemo() {
return (
<div className="tunnel-reference">
<div className="tunnel-reference__variant-grid">
<TunnelSample title="Position Map" note="로컬 SVG · 위치도">
<TunnelSample title="Position Map" note="위치도">
<TunnelFacility variant="positionMap" />
</TunnelSample>
<TunnelSample title="Actual / Detail" note="로컬 SVG · 실사화">
<TunnelSample title="Actual / Detail" note="실사화">
<TunnelFacility variant="actualDetail" />
</TunnelSample>
</div>
<TunnelSample title="Separated" note="같은 개체 · Source 작성 부분 간격">
<TunnelSample title="Separated">
<div className="tunnel-reference__row">
<TunnelFacility variant="positionMap" separated />
<TunnelFacility variant="actualDetail" separated />
+1 -1
View File
@@ -61,7 +61,7 @@ type HMDonutDatum = { id: string; label: string; value: number; color?: string }
- 색상을 지정하지 않으면 `--hm-chart-1`~`5`(HM 그린·브라운·웜 뉴트럴 팔레트 별칭)를 순서대로 사용합니다. 다른 색은 `color`에 CSS 색상/토큰을 전달합니다.
- 범례 라벨은 짧게 유지합니다. Bar·Line·Radar·Area의 범례는 한 줄로 두며, 직접 `flex-wrap`으로 줄바꿈시키면 Recharts가 범례 높이를 과대 측정해 차트 아래에 빈 공간이 생길 수 있습니다. Donut·Pie·Stacked Pie는 컴포넌트 내부에서 줄바꿈 범례를 쓰도록 구성되어 있고, Radial 계열은 범례를 차트 박스 밖 아래에 따로 놓습니다.
- 모든 차트의 `label`은 필수 접근성 이름(`role="group"`의 `aria-label`)입니다.
- 차트는 부모 폭을 따라 줄어듭니다(`w-full`, 높이 `h-64` → `sm:h-80`). `className`으로 높이를 바꿀 수 있습니다. 부모에 `min-width: 0`이 없으면 flex·grid 안에서 넘칠 수 있습니다.
- 차트는 부모 폭을 따라 줄어듭니다(`w-full`, 높이 `h-64` → `sm:h-80`; Donut·Pie·Radar는 `h-72` → `sm:h-80`, Radial은 `300px`). `className`으로 높이를 바꿀 수 있습니다. 부모에 `min-width: 0`이 없으면 flex·grid 안에서 넘칠 수 있습니다.
## 4. 렌더링
@@ -6,7 +6,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import http from 'node:http'
import { swStoryEntries, retiredPropertyPanelIds, retiredUnusedDemoIds, approvedAddedStoryIds, headerInventoryIssues } from './storybook-inventory.mjs'
import { swStoryEntries, retiredPropertyPanelIds, headerInventoryIssues, swScopeIssues } from './storybook-inventory.mjs'
import { loadPlaywright } from './playwright-runtime.mjs'
// Property panel comparison consolidation (2026-10-19).
@@ -39,7 +39,7 @@ const geometry = () => page.evaluate(() => {
const r = e => { const b = e.getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height, right: b.right, bottom: b.bottom } }
const scroller = document.querySelector('[data-compare-scroll]')
return {
cells: [...document.querySelectorAll('[data-compare]')].map(c => ({ id: c.dataset.compare, cell: r(c), panel: r(c.querySelector('.hm-sw-property')), caption: c.querySelector('figcaption strong')?.textContent })),
cells: [...document.querySelectorAll('[data-compare]')].map(c => ({ id: c.dataset.compare, cell: r(c), panel: r(c.querySelector('.hm-sw-property')), caption: c.querySelector('figcaption strong')?.textContent ?? c.getAttribute('aria-label') })),
scroller: scroller && { ...r(scroller), scrollW: scroller.scrollWidth, clientW: scroller.clientWidth, scrollH: scroller.scrollHeight, clientH: scroller.clientHeight },
doc: { scrollW: document.documentElement.scrollWidth, clientW: document.documentElement.clientWidth, scrollH: document.documentElement.scrollHeight, clientH: document.documentElement.clientHeight },
}
@@ -70,12 +70,15 @@ try {
// approved additions, the verification tags and the Popup move).
const recorded = JSON.parse(execFileSync('git', ['show', 'c8e5fbeebc26fba92998fc5ddcd28c6c110e452d:docs/sw/property-panel-comparison-qa/verify/result.json'], { encoding: 'utf8', maxBuffer: 1 << 26 })).data.inventory
const now = headerInventoryIssues(before.sw, index)
// 2026-11-13 (user request): the former whole-Storybook count (338 - retired + approved SW additions) failed whenever a
// Story outside SW was added; the SW Story set is checked instead (swScopeIssues: no unapproved SW addition, deletion or ID change).
const swScope = swScopeIssues(before.sw, index)
report.data.inventory.recorded = { total: recorded.total, sw: recorded.sw }
check('inventory: 24 -> 19 SW Stories, 343 -> 338 total, only 5 approved retirements (recorded at c8e5fbe); now only approved later changes', before.sw.length === 24 && recorded.sw === 19 && before.total === 343 && recorded.total === 338 &&
JSON.stringify(recorded.ids.map(([id]) => id).sort()) === JSON.stringify(expected.map(e => e.id).sort()) && retiredPropertyPanelIds.every(id => !index.entries[id]) &&
stories.length === 338 - retiredUnusedDemoIds.length + approvedAddedStoryIds.length && now.unexpected.length === 0 && now.missing.length === 0 && now.retiredPresent.length === 0, { before: [before.total, before.sw.length], recorded: [recorded.total, recorded.sw], after: [stories.length, sw.length], ...now })
swScope.ok && now.unexpected.length === 0 && now.missing.length === 0 && now.retiredPresent.length === 0, { before: [before.total, before.sw.length], recorded: [recorded.total, recorded.sw], after: [stories.length, sw.length], ...now, swScope })
check('inventory: retained SW Story IDs, names, titles, tags unchanged (recorded at c8e5fbe); now only the approved tags and Popup move', JSON.stringify(recorded.ids) === JSON.stringify(expected.map(e => [e.id, e.name])) && now.changed.length === 0, { retained: sw.length, changed: now.changed })
check('inventory: compare Story keeps its ID and name', index.entries['sw-header--property-panel-compare']?.name === 'Property Panel')
check('inventory: compare Story keeps its ID and name', index.entries['sw-header--property-panel-compare']?.name === '속성창')
check('inventory: Dropdown Story keeps ID / name / title', index.entries['sw-header--composed-controls']?.name === 'Dropdown' && index.entries['sw-header--composed-controls']?.title === 'SW/Header')
// ---- Layout at four widths
@@ -93,7 +96,8 @@ try {
// 2026-10-26 (user request): from 1080px, 3 columns — none/all stacked in column 1, circle and expanded beside them.
// 2026-11-05: the Story page Inner keeps 60px on each side (story-page.css), so the steps moved to 787px / 1125px
// (sw-stories.css .sw-story-compare--grid). It keeps 16px up to 480px (growing to 60px at 768px), so 390px has 358px.
const oneColumn = width < 787, threeColumns = width >= 1125
// 2026-11-13: the side margin maximum is 30px (story-page.css), so sw-stories.css moved the steps to 727px / 1065px.
const oneColumn = width < 727, threeColumns = width >= 1065
if (threeColumns) {
const gaps = { stack: b.cell.y - a.cell.bottom, col1: c.cell.x - Math.max(a.cell.right, b.cell.right), col2: d.cell.x - c.cell.right }
report.data[`gaps${width}`] = gaps
@@ -141,20 +145,30 @@ try {
report.data[`drop-${id}`] = info
check(`Dropdown ${id}: opens, has items, fully inside the viewport`, info.items > 0 && info.x >= 0 && info.y >= 0 && info.right <= info.vw + 0.5 && info.bottom <= info.vh + 0.5, info)
await page.screenshot({ path: path.join(out, `dropdown-${id}.png`) })
if (id === 'expanded') {
await page.keyboard.press('Escape')
check(`Dropdown ${id}: Escape closes and returns focus to its field`, await page.locator('.hm-sw-property__drop').count() === 0 && await trigger.evaluate(e => document.activeElement === e || e.contains(document.activeElement)))
}
// Scroll the page while open (2026-10-22: no Inner scroller): the fixed overlay must stay attached to its field.
const moved = await page.evaluate(() => { const t = scrollY; scrollBy(0, 120); return scrollY - t })
// 2026-11-13: at 1440 × 900 the page (3-column layout since 2026-10-26) fits the viewport and cannot scroll, and the Story
// preview sets `html { scroll-behavior: smooth }` (src/hm-theme.css), so scrollY read right after scrollBy stayed 0. The
// check runs at 1440 × 600, where the page scrolls, with an instant scroll; the viewport and scroll position are restored.
{
const size = page.viewportSize()
await page.setViewportSize({ width: size.width, height: 600 })
await page.evaluate(() => scrollTo({ top: 0, behavior: 'instant' }))
const { trigger, info } = await openPicker('expanded')
const moved = await page.evaluate(() => { const t = scrollY; scrollBy({ top: 120, behavior: 'instant' }); return scrollY - t })
await page.waitForTimeout(150)
const after = await page.evaluate(() => {
const d = document.querySelector('.hm-sw-property__drop')?.getBoundingClientRect()
const f = document.querySelector('[data-compare="expanded"] [data-property-id="layer"] [aria-expanded="true"]')?.getBoundingClientRect()
return d && f ? { dTop: d.top, dBottom: d.bottom, fTop: f.top, fBottom: f.bottom } : null
})
report.data.dropScroll = { moved, before: info, after }
report.data.dropScroll = { viewport: { width: size.width, height: 600 }, moved, before: info, after }
check('Dropdown follows the page scroll (stays attached to its field)', moved > 0 && after && (Math.abs(after.dTop - after.fBottom) < 1.5 || Math.abs(after.dBottom - after.fTop) < 1.5), report.data.dropScroll)
}
await page.keyboard.press('Escape')
check(`Dropdown ${id}: Escape closes and returns focus to its field`, await page.locator('.hm-sw-property__drop').count() === 0 && await trigger.evaluate(e => document.activeElement === e || e.contains(document.activeElement)))
await trigger.evaluate(() => scrollTo({ top: 0, behavior: 'instant' }))
await page.setViewportSize(size)
}
// Select a layer value: callback updates only that panel.
{
+25 -5
View File
@@ -106,11 +106,16 @@ export const menuCleanupHiddenIds = ['sw-header--interactive-states', 'sw-header
'sw-viewer--compare-with-sw-header',
// 2026-10-22 (user request): SW/Popup keeps only Property Panel in the sidebar.
'sw-popup--custom-content', 'sw-popup--multiple-popups']
export const renamedEntries = { 'sw-header--overview': 'SW Header', 'sw-footer--full-footer': 'SW Footer',
'sw-header--logo-slot': 'Logo', // 2026-10-22 (user request): sidebar "Logo", page title "SW Header — 로고"
'sw-header--property-panel-compare': 'Property Panel', // 2026-10-22 (user request): was "Property Panel · 4개 비교"
'sw-footer--responsive': 'Responsive More', 'sw-viewer--content-variants': 'Settings Popup',
'sw-viewer--responsive': 'Responsive More' }
// 2026-11-13 (user request): the expected names follow the current approved Story names (Korean menu names); IDs unchanged.
export const renamedEntries = { 'sw-header--overview': 'SW Header', 'sw-footer--full-footer': 'SW 명령어바',
'sw-header--logo-slot': '로고', // 2026-10-22 (user request): sidebar "Logo", page title "SW Header — 로고"; now 로고
'sw-header--property-panel-compare': '속성창', // 2026-10-22 (user request): was "Property Panel · 4개 비교", then Property Panel
'sw-header--responsive-more': '화면 너비',
'sw-footer--responsive': '화면 너비', 'sw-viewer--content-variants': '설정창',
'sw-viewer--responsive': '화면 너비' }
// Approved commit 2378663 (2026-11-09, "localize sidebar menus and hide unused stories"): Logo Slot left the default sidebar
// with the `internal` tag (Story ID and direct URL kept). Only that tag is added to the recorded entry.
export const internalTaggedIds = ['sw-header--logo-slot']
// Every approved SW Story ID added after the unused-demo removal, as the current index lists them.
export const approvedAddedStoryIds = [...integratedStoryIds.filter(id => !retiredToolIds.includes(id)), ...homeMenuSidebarIds,
...Object.values(movedHomeMenuIds).filter(id => !retiredHomeMenuCompareIds.includes(id)), ...footerStoryIds, ...viewerStoryIds, ...themeStoryIds]
@@ -120,6 +125,7 @@ export const withApprovedOrder = entries => [...entries.filter(entry => entry.id
// An earlier index entry with the approved tag / Popup move applied (2b0e1a2; the moved Story has no component path).
export const withApprovedEntryChanges = entry => {
if (renamedEntries[entry.id]) entry = { ...entry, name: renamedEntries[entry.id] }
if (internalTaggedIds.includes(entry.id)) entry = { ...entry, tags: [...entry.tags, 'internal'] }
if (verificationTaggedIds.includes(entry.id)) return { ...entry, tags: [...entry.tags, 'verification'] }
if (!movedToPopupIds.includes(entry.id)) return entry
const { componentPath, ...rest } = entry
@@ -145,6 +151,20 @@ export const movedHeaderDeclarations = { PropertyPanelCompare: ['src/sw/sw-prope
export const laterRetiredHeaderIds = [...retiredPropertyPanelIds, ...retiredUnusedDemoIds]
// The approved additions that sit under SW/Header.
export const approvedAddedHeaderIds = approvedAddedStoryIds.filter(id => id.startsWith('sw-header--'))
// User-approved SW/Step Header Stories (adbb034, 2026-11-03; navigation labels b67a3ec), a SW group added after the lists above.
export const stepHeaderStoryIds = ['process-header', 'responsive', 'split-open', 'multi-step', 'fixed-widths', 'process-bar',
'multi-step-many-groups', 'multi-step-many-steps', 'states', 'theme-override'].map(id => `sw-step-header--${id}`)
// 2026-11-13 (user request): the SW Story set, separate from the whole Storybook count. Every Story whose title starts with
// "SW/" must be either a retained SW/Header Story of the earlier list (approved changes applied) or an approved addition, and
// every one of those must exist: `unexpected` catches an unapproved SW addition or an ID change, `missing` a deletion or an
// ID change. Stories outside SW may be added or removed without affecting it.
export const swScopeIssues = (entries, index) => {
const retained = retainedInventory(entries).filter(entry => !laterRetiredHeaderIds.includes(entry.id)).map(withApprovedEntryChanges).map(entry => entry.id)
const approved = [...new Set([...retained, ...approvedAddedStoryIds, ...approvedAddedHeaderIds, ...stepHeaderStoryIds])]
const current = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('SW/')).map(entry => entry.id)
const issues = { actual: current.length, expected: approved.length, unexpected: current.filter(id => !approved.includes(id)), missing: approved.filter(id => !current.includes(id)) }
return { ...issues, ok: issues.actual === issues.expected && issues.unexpected.length === 0 && issues.missing.length === 0 }
}
// An earlier SW/Header entry list against today's index: `changed` (ID, name, title or tags differ from the earlier entry
// with the approved changes applied), `unexpected` (SW/Header Stories neither retained nor approved), `missing` (approved
// additions not under SW/Header) and `retiredPresent` (an approved retirement back in the index).
@@ -44,7 +44,7 @@ const keys = Object.keys(afterIndex.entries), firstSW = keys.findIndex(id => id.
const swOrder = { previous: keys[firstSW - 1], afterCenterGraphic: afterIndex.entries[keys[firstSW - 1]]?.title.startsWith('Center Graphic/') && !keys.slice(firstSW + 1).some(id => afterIndex.entries[id].title.startsWith('Center Graphic/')), contiguous: keys.filter(id => id.startsWith('sw-')).every((id, i) => keys[firstSW + i] === id) }
check('inventory: every non-removed index entry identical (docs and other packages included) except the approved SW changes', outsideSW(others(beforeIndex)) === outsideSW(others(afterIndex)) && JSON.stringify(others(beforeIndex).filter(e => e.id.startsWith('sw-')).map(withApprovedEntryChanges)) === JSON.stringify(others(beforeIndex).filter(e => e.id.startsWith('sw-')).map(e => afterIndex.entries[e.id])) && swOrder.afterCenterGraphic && swOrder.contiguous, swOrder)
check('inventory: Dropdown keeps sw-header--composed-controls / Dropdown / SW/Header', afterIndex.entries['sw-header--composed-controls']?.name === 'Dropdown' && afterIndex.entries['sw-header--composed-controls']?.title === 'SW/Header')
check('inventory: comparison keeps its ID and name', afterIndex.entries['sw-header--property-panel-compare']?.name === 'Property Panel')
check('inventory: comparison keeps its ID and name', afterIndex.entries['sw-header--property-panel-compare']?.name === '속성창')
// ---- Source: retained Story declarations byte-identical; product, AI template and other packages untouched.
const sourcePath = 'src/sw/sw-header.stories.tsx'
@@ -89,8 +89,12 @@ let reference, viewerMenu, moreMenu, refTrigger
const menu = header.getByRole('menu', { name: '추가 도구' })
reference = await face(menu, MORE_ITEM)
refTrigger = await more.evaluate(element => { const s = getComputedStyle(element); return `${s.borderTopWidth} ${s.borderTopStyle} ${s.borderTopColor} | ${s.borderTopLeftRadius} | ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor} | ${s.boxShadow}` })
const responsive = await newPage(1920, 1500)
// 2026-11-13: 2500px wide (like viewer.browser.mjs), so the 1920 row, shown only on a page of 1980px or more, is visible too.
const responsive = await newPage(2500, 1500)
await open(responsive, 'sw-viewer--responsive')
// 2026-11-13: since 2026-10-26 More shows only once even the narrowest tabs do not fit (below about 520px), so the 520 row
// is narrowed in place to 400px first (as viewer.browser.mjs does) to open the same More menu.
await responsive.evaluate(async () => { document.querySelector('[data-width="520"]').style.maxWidth = '400px'; await new Promise(resolve => setTimeout(resolve, 400)) })
await responsive.locator('[data-width="520"] .hm-sw-viewer-header').getByRole('button', { name: '추가 도구' }).click()
const more520 = responsive.getByRole('menu', { name: '추가 도구' })
moreMenu = await face(more520, VIEWER_ITEM, '.hm-sw-viewer-menu__label', '.hm-sw-viewer-menu__separator', '.hm-sw-viewer-menu__icon', '.hm-sw-viewer-menu__text')
@@ -129,12 +133,17 @@ let reference, viewerMenu, moreMenu, refTrigger
const layout = await header.evaluate(element => {
const button = element.querySelector('[data-document-open]'), wrapper = button.parentElement
return { surface: button.classList.contains('hm-sw-header__document-button') && wrapper.matches('.hm-sw-header__document[data-tool]'), afterLogo: wrapper.previousElementSibling?.classList.contains('hm-sw-viewer-header__brand'),
beforeName: wrapper.nextElementSibling?.classList.contains('hm-sw-viewer-header__document'), label: button.querySelector('.hm-sw-viewer-header__open-label')?.textContent, icons: button.querySelectorAll('.hm-sw-viewer-header__open-icon svg').length }
beforeName: wrapper.nextElementSibling?.classList.contains('hm-sw-viewer-header__document'), label: button.querySelector('.hm-sw-viewer-header__open-label')?.textContent,
// 2026-11-13: the folder icon carries the viewer icon outline (SWViewerIcon, caf4c32 2026-10-21): one icon (glyph) SVG and
// one outline copy behind it, nothing else.
icons: button.querySelectorAll('.hm-sw-viewer-header__open-icon svg').length, glyphs: button.querySelectorAll('.hm-sw-viewer-header__open-icon .hm-sw-viewer-icon__glyph svg').length,
outlines: button.querySelectorAll('.hm-sw-viewer-header__open-icon .hm-sw-viewer-icon__outline svg').length }
})
check('file open button after the logo: SWHeader document surface, icon + 파일 열기', layout.surface && layout.afterLogo && layout.beforeName && layout.label === '파일 열기' && layout.icons === 1, layout)
const iconBox = await openButton.locator('.hm-sw-viewer-header__open-icon').evaluate(element => { const s = getComputedStyle(element), r = element.getBoundingClientRect(); return { style: `${s.borderTopWidth} ${s.borderTopStyle} ${s.borderTopColor} | ${s.borderTopLeftRadius} | ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor} | ${s.boxShadow}`, size: [Math.round(r.width), Math.round(r.height)] } })
// 2026-10-21 user request: the icon has no border and no face (transparent); 34px slot kept.
check('file open icon: no border, transparent, no shadow; 34px slot', iconBox.style.startsWith('0px none') && iconBox.style.includes('rgba(0, 0, 0, 0)') && iconBox.style.endsWith('none') && iconBox.size.join() === '34,34', iconBox)
check('file open button after the logo: SWHeader document surface, icon (1 glyph + 1 outline SVG) + 파일 열기', layout.surface && layout.afterLogo && layout.beforeName && layout.label === '파일 열기' && layout.glyphs === 1 && layout.outlines === 1 && layout.icons === 2, layout)
const iconBox = await openButton.locator('.hm-sw-viewer-header__open-icon').evaluate(element => { const s = getComputedStyle(element), r = element.getBoundingClientRect(), b = element.closest('[data-document-open]').getBoundingClientRect(); return { style: `${s.borderTopWidth} ${s.borderTopStyle} ${s.borderTopColor} | ${s.borderTopLeftRadius} | ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor} | ${s.boxShadow}`, size: [Math.round(r.width), Math.round(r.height)], button: [Math.round(b.width), Math.round(b.height)] } })
// 2026-10-21 user request: the icon has no border and no face (transparent). 2026-11-13: the icon area is the 16 × 16 icon
// itself with no margin (0840d76, DESIGN_RULES --hm-sw-viewer-icon-size); the click area is the button, at least 34px each way.
check('file open icon: no border, transparent, no shadow; 16 × 16 icon, button click area ≥ 34px', iconBox.style.startsWith('0px none') && iconBox.style.includes('rgba(0, 0, 0, 0)') && iconBox.style.endsWith('none') && iconBox.size.join() === '16,16' && iconBox.button.every(side => side >= 34), iconBox)
// File-name area (.hm-sw-viewer-header__document): no left padding, right after the file open button; the file-name
// button inside keeps its own padding (user decision 2026-10-21).
const nameStart = await header.evaluate(element => { const d = element.querySelector('.hm-sw-viewer-header__document'); return [getComputedStyle(d).paddingLeft, Math.round(d.getBoundingClientRect().left - element.querySelector('[data-document-open]').getBoundingClientRect().right)] })
@@ -179,18 +188,21 @@ let reference, viewerMenu, moreMenu, refTrigger
const openState = width => header.evaluate(async (element, width) => {
element.style.width = `${width}px`
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
const button = element.querySelector('[data-document-open]'), icon = button.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect()
return { width, label: !!button.querySelector('.hm-sw-viewer-header__open-label'), aria: button.getAttribute('aria-label'), title: button.title, icon: [Math.round(icon.width), Math.round(icon.height)], overflow: element.scrollWidth - element.clientWidth }
const button = element.querySelector('[data-document-open]'), icon = button.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect(), area = button.getBoundingClientRect()
return { width, label: !!button.querySelector('.hm-sw-viewer-header__open-label'), aria: button.getAttribute('aria-label'), title: button.title, icon: [Math.round(icon.width), Math.round(icon.height)], button: [Math.round(area.width), Math.round(area.height)], overflow: element.scrollWidth - element.clientWidth }
}, width)
const sweep = []
for (let width = 1440; width >= 400; width -= 20) sweep.push(await openState(width))
const back = []
for (let width = 400; width <= 1440; width += 20) back.push(await openState(width))
const firstIcon = sweep.find(item => !item.label)
check('1440: icon + 파일 열기; narrower: icon only (tooltip / name 파일 열기, 34px icon slot kept)', sweep[0].label && firstIcon
&& sweep.filter(item => !item.label).every(item => item.aria === '파일 열기' && item.title === '파일 열기' && item.icon.join() === '34,34'), { first: sweep[0], firstIcon })
// 2026-11-13: at every width the icon stays the 16 × 16 icon area (0840d76) and the button keeps a click area of at least 34px.
check('1440: icon + 파일 열기; narrower: icon only (tooltip / name 파일 열기); 16 × 16 icon and ≥ 34px click area at every width', sweep[0].label && firstIcon
&& sweep.filter(item => !item.label).every(item => item.aria === '파일 열기' && item.title === '파일 열기')
&& [...sweep, ...back].every(item => item.icon.join() === '16,16' && item.button.every(side => side >= 34)), { first: sweep[0], firstIcon, icons: [...new Set([...sweep, ...back].map(item => item.icon.join()))], minButton: [Math.min(...sweep.map(item => item.button[0])), Math.min(...sweep.map(item => item.button[1]))] })
check('label comes back at the same widths when the header grows; no overflow', sweep.every(item => back.find(other => other.width === item.width).label === item.label) && [...sweep, ...back].every(item => item.overflow <= 0))
await header.evaluate(element => { element.style.width = '520px' })
// 2026-11-13: More shows only below about 520px since 2026-10-26 (viewer.browser.mjs), so the header is set to 400px.
await header.evaluate(element => { element.style.width = '400px' })
await page.waitForTimeout(60)
const more = header.getByRole('button', { name: '추가 도구' })
await more.click()
@@ -223,7 +235,8 @@ let reference, viewerMenu, moreMenu, refTrigger
// ---------- Responsive ----------
{
// Tall enough that the 520 row and its popups are inside the viewport (the Story page does not scroll them in).
const page = await newPage(1920, 1500)
// 2026-11-13: 2500px wide so the 1920 row (shown only on a page of 1980px or more) is measured with the others.
const page = await newPage(2500, 1500)
await open(page, 'sw-viewer--responsive')
const headers = page.locator('[data-width] .hm-sw-viewer-header')
const report = []
@@ -251,12 +264,21 @@ let reference, viewerMenu, moreMenu, refTrigger
check('layer / settings / properties always in the header', report.every(item => JSON.stringify(item.toggles.map(toggle => toggle.name)) === '["레이어","설정","속성"]'), report.map(item => item.toggles.map(toggle => toggle.name)))
// 2026-10-21 measured steps: full → icons only → More, by the measured room (no fixed width). With the file open button
// after the logo, this Story's content fits fully at 1920 / 1440, as icons at 1280 / 950, and needs More at 860 / 760 / 520.
// 2026-11-13: since 2026-10-26 (user request; viewer.browser.mjs) the file name and the Model / Layout tabs give way first,
// so 860 / 760 / 520 keep every item as icons without More; More shows only below about 520px (the 520 row narrowed to 400).
check('1920 / 1440: full items with labels (전체 보기 shown), no More; 1280 / 950: icons only, no More', report.slice(0, 2).every(item => item.fitShown && !item.more && item.toggles.every(toggle => toggle.label))
&& report.slice(2, 4).every(item => !item.more && item.fitShown && item.toggles.every(toggle => !toggle.label && toggle.title)), report.map(item => [item.width, item.fitShown, item.more]))
check('860 / 760: icons only (tooltips kept) + More', report.slice(4, 6).every(item => item.more && item.toggles.every(toggle => !toggle.label && toggle.title)), report.slice(4, 6))
check('520: icons only, then More for what still does not fit', report[6].more && report[6].toggles.every(toggle => !toggle.label && toggle.title), report[6])
check('860 / 760: icons only (tooltips kept), no More', report.slice(4, 6).every(item => !item.more && item.toggles.every(toggle => !toggle.label && toggle.title)), report.slice(4, 6))
check('520: icons only (tooltips kept), no More', !report[6].more && report[6].toggles.every(toggle => !toggle.label && toggle.title), report[6])
check('logo, file name and toolbar never overlap', report.every(item => item.logoRight <= item.docLeft + 2 + 0.5 && item.docRight <= item.objectLeft + 0.5), report.map(item => [item.width, item.logoRight, item.docLeft, item.docRight, item.objectLeft]))
// Every item is either in the header or in More, never both and never lost.
// Every item is either in the header or in More, never both and never lost: checked on the 520 row narrowed to 400px.
const narrowed = await page.evaluate(async () => {
const wrapper = document.querySelector('[data-width="520"]'); wrapper.style.maxWidth = '400px'
await new Promise(resolve => setTimeout(resolve, 400))
const header = wrapper.querySelector('.hm-sw-viewer-header')
return { width: Math.round(header.getBoundingClientRect().width), overflow: header.scrollWidth - header.clientWidth, more: !!header.querySelector('.hm-sw-viewer-header__more') }
})
check('520 row narrowed to 400: More shown, no overflow', narrowed.more && narrowed.overflow <= 0, narrowed)
const more = headers.nth(6).getByRole('button', { name: '추가 도구' })
await more.click()
const moreMenu = page.getByRole('menu', { name: '추가 도구' })
@@ -264,8 +286,9 @@ let reference, viewerMenu, moreMenu, refTrigger
const inHeader = await headers.nth(6).evaluate(element => [...element.querySelectorAll('.hm-sw-viewer-header__object :is(.hm-sw-viewer-header__button:not(.hm-sw-viewer-header__more), [role=radio], .hm-sw-viewer-header__switch-label)')]
.map(item => item.getAttribute('aria-label') ?? item.textContent.trim().replace(/[\d,]+$/, '')))
const all = ['Model', 'Layout', '전체 보기', '2.5D', '어두운 배경', '레이어', '설정', '속성']
check('520: header + More hold every item exactly once (panel toggles never in More)', all.every(name => inHeader.includes(name) !== moreItems.includes(name)) && !moreItems.some(item => ['레이어', '설정', '속성'].includes(item)), { inHeader, moreItems })
check('520 row at 400: header + More hold every item exactly once (panel toggles never in More)', all.every(name => inHeader.includes(name) !== moreItems.includes(name)) && !moreItems.some(item => ['레이어', '설정', '속성'].includes(item)), { inHeader, moreItems })
await page.keyboard.press('Escape')
await page.evaluate(async () => { document.querySelector('[data-width="520"]').style.maxWidth = ''; await new Promise(resolve => setTimeout(resolve, 400)) })
// Icon toggle at 520 still reports pressed / commands.
const icon = headers.nth(3).getByRole('button', { name: '레이어', exact: true })
await icon.click()
@@ -289,14 +312,15 @@ let reference, viewerMenu, moreMenu, refTrigger
for (const [name, dialog] of [['레이어', /레이어 \(/], ['설정', '옵션'], ['속성', '속성']]) {
const toggle = header.getByRole('button', { name, exact: true })
await toggle.hover()
const hover = await toggle.evaluate(element => getComputedStyle(element).backgroundColor)
// 2026-11-13: Viewer Header hover (caf4c32, 2026-10-21 user request): no face (transparent), text / icon #FFC600.
const hover = await toggle.evaluate(element => `${getComputedStyle(element).backgroundColor} / ${getComputedStyle(element).color}`)
await toggle.click()
const panel = page.getByRole('dialog', { name: dialog })
panels.push({ name, iconOnly: (await toggle.locator('.hm-sw-viewer-header__label').count()) === 0, title: await toggle.getAttribute('title'), hover, open: await panel.isVisible(), pressed: await toggle.getAttribute('aria-pressed'), color: await toggle.evaluate(element => getComputedStyle(element).color) })
await toggle.click()
panels.at(-1).closed = (await panel.count()) === 0 && (await toggle.getAttribute('aria-pressed')) === 'false'
}
check(`narrow header (${width}px): icon toggles with tooltip, hover, pressed #FFC600, panel open / close`, width < 900 && panels.every(item => item.iconOnly && item.title && item.hover === 'rgb(38, 102, 81)' && item.open && item.pressed === 'true' && item.color === 'rgb(255, 198, 0)' && item.closed), panels)
check(`narrow header (${width}px): icon toggles with tooltip, hover (no face, #FFC600), pressed #FFC600, panel open / close`, width < 900 && panels.every(item => item.iconOnly && item.title && item.hover === 'rgba(0, 0, 0, 0) / rgb(255, 198, 0)' && item.open && item.pressed === 'true' && item.color === 'rgb(255, 198, 0)' && item.closed), panels)
await page.screenshot({ path: path.join(out, `viewer-narrow-${width}.png`), clip: { ...(await box(header)), height: 56 } })
await page.close()
}
@@ -353,6 +377,11 @@ let reference, viewerMenu, moreMenu, refTrigger
// The header's own width is swept (10px steps) down and back up. Stage per width: 0 full, 1 icons only, 2+ More with
// n items. Icons always come before More; narrower never has fewer steps; the way back gives the same state at
// every width (no flicker, no stuck item); no overflow. A shorter file name moves every boundary to a narrower width.
// 2026-11-13 (current approved rules): the panel toggles turn into icons before 전체 보기 (2026-10-23, user-approved) and a
// button that became an icon stays one when narrower; below 560px the file name gives way to 56px (2026-10-26), so items
// may come back from More into the header — instead of a falling item count, every width that shows More opens it and every
// command must be in the header or in More exactly once. The steps use a header-width basis, not the file name width
// (caf4c32, 2026-10-21), so a shorter file name keeps the same boundaries.
const page = await newPage(1920, 900)
await open(page, 'sw-viewer--viewer-header')
const header = page.locator('.hm-sw-viewer-header').first()
@@ -365,8 +394,32 @@ let reference, viewerMenu, moreMenu, refTrigger
const iconOnly = icons.length > 0 && icons.every(button => !button.querySelector('.hm-sw-viewer-header__label'))
const anyIcon = icons.some(button => !button.querySelector('.hm-sw-viewer-header__label'))
const more = !!element.querySelector('.hm-sw-viewer-header__more')
return { width, shown, iconOnly, anyIcon, more, stage: more ? 2 : iconOnly ? 1 : 0, overflow: Math.max(element.scrollWidth - element.clientWidth, object.scrollWidth - object.clientWidth - 1) }
const nameOf = button => button.getAttribute('aria-label') ?? button.textContent.trim()
const iconNames = icons.filter(button => !button.querySelector('.hm-sw-viewer-header__label')).map(nameOf)
const labelNames = icons.filter(button => button.getClientRects().length && button.querySelector('.hm-sw-viewer-header__label')).map(nameOf)
return { width, shown, iconOnly, anyIcon, iconNames, labelNames, more, stage: more ? 2 : iconOnly ? 1 : 0, overflow: Math.max(element.scrollWidth - element.clientWidth, object.scrollWidth - object.clientWidth - 1) }
}, width)
// Commands shown in the header (visible only; a switch is named by its full label) and in the opened More menu.
const COMMANDS = ['Model', 'Layout', '전체 보기', '2.5D', '어두운 배경', '레이어', '설정', '속성']
const PANEL_TOGGLES = ['레이어', '설정', '속성']
const commandsAt = async width => {
const inHeader = await header.evaluate(async (element, width) => {
element.style.width = `${width}px`
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
return [...element.querySelectorAll('.hm-sw-viewer-header__object :is(button:not(.hm-sw-viewer-header__more), [role=radio], [role=switch])')]
.filter(item => item.getClientRects().length)
.map(item => item.getAttribute('role') === 'switch' ? element.querySelector(`label[for="${item.id}"] .hm-sw-viewer-header__switch-label-full`)?.textContent.trim()
: (item.getAttribute('aria-label') ?? item.textContent.trim()).replace(/[\d,]+$/, ''))
}, width)
await header.getByRole('button', { name: '추가 도구' }).click()
const menu = page.getByRole('menu', { name: '추가 도구' })
await menu.waitFor({ timeout: 2000 })
const inMore = (await menu.locator('[role^=menuitem]').allInnerTexts()).map(text => text.split(' · ')[0].trim())
const menuBox = await menu.evaluate(element => { const r = element.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, vw: innerWidth, vh: innerHeight } })
await page.keyboard.press('Escape')
const closed = (await page.getByRole('menu', { name: '추가 도구' }).count()) === 0
return { width, inHeader: inHeader.filter(name => COMMANDS.includes(name)), inMore, menuBox, closed, pageOverflow: await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) }
}
const sweep = async () => {
const down = [], up = []
for (let width = 1440; width >= 400; width -= 10) down.push(await state(width))
@@ -376,17 +429,33 @@ let reference, viewerMenu, moreMenu, refTrigger
const boundaries = list => ({ icons: list.find(item => item.stage >= 1)?.width, more: list.find(item => item.stage === 2)?.width })
const long = await sweep()
check('sweep: no overflow at any width (400–1440)', [...long.down, ...long.up].every(item => item.overflow <= 0), [...long.down, ...long.up].filter(item => item.overflow > 0).map(item => item.width))
check('sweep: icons switch together and always before More', [...long.down, ...long.up].every(item => item.anyIcon === item.iconOnly && (!item.more || item.iconOnly)))
check('sweep: narrower never shows more (monotonic: full → icons → More, fewer items)', long.down.every((item, i) => i === 0 || (item.stage >= long.down[i - 1].stage && item.shown <= long.down[i - 1].shown)))
check('sweep: icons always before More; panel toggles turn into icons before 전체 보기', [...long.down, ...long.up].every(item => (!item.more || item.iconOnly)
&& (!item.iconNames.includes('전체 보기') || PANEL_TOGGLES.every(name => item.iconNames.includes(name)))),
[...long.down, ...long.up].filter(item => (item.more && !item.iconOnly) || (item.iconNames.includes('전체 보기') && !PANEL_TOGGLES.every(name => item.iconNames.includes(name)))).map(item => [item.width, item.iconNames]))
// A button that became an icon never shows its label again at a narrower width (it may move on into More).
check('sweep: a button that became an icon never gets its label back when narrower', long.down.every((item, i) => i === 0 || !long.down[i - 1].iconNames.some(name => item.labelNames.includes(name))),
long.down.filter((item, i) => i && long.down[i - 1].iconNames.some(name => item.labelNames.includes(name))).map(item => [item.width, item.labelNames]))
// Every width (10px steps) that shows More: More opened for real; each command exactly once in the header or in More, the panel
// toggles never in More, the menu inside the viewport and closed by Escape, no page overflow. Items moving back into the header
// when the file name gives way (below 560px) are allowed.
const moreWidths = long.down.filter(item => item.more).map(item => item.width)
const commands = []
for (const width of moreWidths) commands.push(await commandsAt(width))
const commandIssues = commands.filter(item => !COMMANDS.every(name => item.inHeader.filter(other => other === name).length + item.inMore.filter(other => other === name).length === 1)
|| item.inMore.some(name => PANEL_TOGGLES.includes(name)) || item.inHeader.length + item.inMore.length !== COMMANDS.length
|| !(item.menuBox.left >= 0 && item.menuBox.top >= 0 && item.menuBox.right <= item.menuBox.vw + 0.5 && item.menuBox.bottom <= item.menuBox.vh + 0.5) || !item.closed || item.pageOverflow > 0)
check(`sweep: every command exactly once in the header or More at each More width (${moreWidths.length} widths), menu inside the viewport, Escape closes`, moreWidths.length > 0 && commandIssues.length === 0,
commandIssues.length ? commandIssues.slice(0, 3) : { widths: [moreWidths.at(0), moreWidths.at(-1)], sample: commands.at(-1) })
check('sweep: growing back gives the same state at every width (reverse order, no stuck item)', long.down.every(item => { const back = long.up.find(other => other.width === item.width); return back.stage === item.stage && back.shown === item.shown }))
check('sweep: 1440 full; each stage reached by measured room only', long.down[0].stage === 0 && long.down.at(-1).stage === 2, boundaries(long.down))
// A short file name: the same content needs less room, so each step happens at a narrower width.
// A short file name: the steps use the header-width basis of the file name area (caf4c32, 2026-10-21: short and long names
// give the same result), so every boundary stays where it is with a long name.
await header.evaluate(element => { element.style.width = '' })
await header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click()
await header.getByRole('button', { name: 'simple.dxf' }).click()
const short = await sweep()
const [a, b] = [boundaries(long.down), boundaries(short.down)]
check('sweep: shorter file name moves the steps to narrower widths', b.icons < a.icons && b.more < a.more, { long: a, short: b })
check('sweep: file name length does not move the steps (same boundaries for a short name)', a.icons !== undefined && a.more !== undefined && b.icons === a.icons && b.more === a.more, { long: a, short: b })
await page.close()
}
+209 -19
View File
@@ -1,16 +1,16 @@
import type { ComponentType } from "react"
import type { ComponentType, ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { TunnelFacility } from "@hm-design/center-graphic"
import {
BridgeReferenceDemo,
IntersectionReferenceDemo,
InterchangeReferenceDemo,
JunctionReferenceDemo,
LinkedFacilityReferenceDemo,
RoadsideReferenceDemo,
TunnelReferenceDemo,
} from "@hm-design/center-graphic/reference"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
CompactFacilityConnection,
FACILITY_CONFIGS,
LocalAssetFacility,
RoadsideFacilityTag,
TunnelFacility,
type FacilityConfig,
type RoadsideEntity,
} from "@hm-design/center-graphic"
import { LinkedFacilityReferenceDemo } from "@hm-design/center-graphic/reference"
import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { withRepresentation } from "./center-shared"
// Owners: ui-viz docs/visualization/facility.md and facility-assets.md (128 approved SVG references, shipped unchanged).
@@ -25,35 +25,225 @@ export default meta
type Story = StoryObj<typeof meta>
// Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic.
// 2026-11-13: no visible "개요" section title (사업명 page rule); the specimens keep their indent via the common `sb-page-indent`.
const page = (name: string, Demo: ComponentType): Story => ({
name,
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title={name} />
<StorySection title="개요">
<div className="sb-page-indent">
{withRepresentation(() => <Demo />)}
</StorySection>
</div>
</StoryPage>
),
})
// 터널 page layout (2026-11-13): composed here from the public API (same props as the package TunnelReferenceDemo) so the
// specimens can be grouped — one width-behaviour section with a Position Map column (SEPARATED → SHORT → MEDIUM → LONG) and an
// Actual / Detail column (SEPARATED → ACTUAL SHORT → REF → LONG), same steps on the same row (1 column below a 1180px content
// width), and the compact applied example full width below. The former standalone Position Map / Actual examples are the
// MEDIUM / ACTUAL REF rows (same default name and length). Package classes are reused so the common title row, 12px title gap and the
// 8px local scrollbar still apply; the layout lives in center-stories.css (`.sb-center-tunnel*`).
const TUNNEL_WIDTH_NAMES: [string, string, string][] = [
["SHORT", "터널", "L=80m"],
["MEDIUM", "대산2터널", "L=750m"],
["LONG", "서해안고속도로 대산연결 제2터널", "L=12,480m"],
]
const TUNNEL_ACTUAL_LABELS = ["ACTUAL SHORT", "ACTUAL REF", "ACTUAL LONG"]
function TunnelSample({ title, note, children }: { title: string; note?: string; children: ReactNode }) {
return (
<article className="tunnel-reference__sample">
<header>
<strong>{title}</strong>
{note ? <span>{note}</span> : null}
</header>
<div className="tunnel-reference__stage">{children}</div>
</article>
)
}
function TunnelSpecimens() {
return (
<div className="sb-center-tunnel">
<div className="tunnel-reference">
{/* 2026-11-13: no "너비 동작" title row; each column carries the common specimen title (English + Korean note). */}
<article className="tunnel-reference__sample">
<div className="tunnel-reference__stage sb-center-tunnel__width-stage">
<div className="sb-center-tunnel__width">
{(["positionMap", "actualDetail"] as const).map((variant) => (
<div className="tunnel-reference__width-tests sb-center-tunnel__width-group" key={variant}>
<header>
<strong>{variant === "positionMap" ? "Position Map" : "Actual / Detail"}</strong>
<span>{variant === "positionMap" ? "위치도" : "실사화"}</span>
</header>
<div>
<span>SEPARATED</span>
<TunnelFacility variant={variant} separated />
</div>
{TUNNEL_WIDTH_NAMES.map(([size, name, length], index) => (
<div key={size}>
<span>{variant === "positionMap" ? size : TUNNEL_ACTUAL_LABELS[index]}</span>
<TunnelFacility name={name} secondaryText={length} variant={variant} />
</div>
))}
</div>
))}
</div>
</div>
</article>
<TunnelSample title="간략 적용 예제" note="노선 + 연결 + 터널 + 레이블">
<CompactFacilityConnection className="tunnel-applied-example" routeMode="inline" segmentShape="tunnel" sourceStatus="SOURCE_RECEIVED" label="노선과 터널 시설물의 간략 적용 예제">
<TunnelFacility name="대산2터널" secondaryText="L=750m" variant="positionMap" />
</CompactFacilityConnection>
</TunnelSample>
</div>
</div>
)
}
// 교량 · 교차로 · 분기점 · 나들목 page layout (2026-11-13): the 터널 layout applied to the package FacilityReferenceDemo
// specimens, composed from the public API with the same props. Position Map | Actual / Detail columns (SEPARATED first, then
// SHORT / MEDIUM / LONG and ACTUAL SHORT / REF / LONG on the same rows), the compact applied example full width below. The former
// standalone Position Map / Actual examples are the MEDIUM / ACTUAL REF rows (default name and secondary text) and the former
// Separated example is each column's first row. `data-sb-compare-at` raises the 2-column breakpoint when the widest row needs it
// (교량 LONG 511px artwork: 1210px); without it the 터널 1180px rule applies.
function facilityWidthNames(config: FacilityConfig) {
return [
config.id === "bridge" ? "1교" : "신시",
config.defaultName,
config.id === "bridge" ? "서해안고속도로 대산연결 제2교량" : `서해안고속도로 ${config.defaultName}`,
]
}
function FacilitySpecimens({ config, compareAt }: { config: FacilityConfig; compareAt?: "1210" }) {
const widthNames = facilityWidthNames(config)
return (
<div className="sb-center-tunnel" data-sb-compare-at={compareAt}>
<div className="facility-reference" data-reference-facility={config.id}>
<article className="facility-reference__sample">
<div className="facility-reference__stage sb-center-tunnel__width-stage">
<div className="sb-center-tunnel__width">
{(["positionMap", "actualDetail"] as const).map((variant) => (
<div className="facility-reference__width-tests sb-center-tunnel__width-group" key={variant}>
<header>
<strong>{variant === "positionMap" ? "Position Map" : "Actual / Detail"}</strong>
<span>{variant === "positionMap" ? "위치도" : "실사화"}</span>
</header>
<div>
<span>SEPARATED</span>
<LocalAssetFacility config={config} variant={variant} separated />
</div>
{widthNames.map((name, index) => (
<div key={name}>
<span>{variant === "positionMap" ? ["SHORT", "MEDIUM", "LONG"][index] : `ACTUAL ${["SHORT", "REF", "LONG"][index]}`}</span>
<LocalAssetFacility config={config} name={name} variant={variant} />
</div>
))}
</div>
))}
</div>
</div>
</article>
<TunnelSample
title="간략 적용 예제"
note={config.id === "interchange" ? "SOURCE_PENDING · 기존 Preview 전용; 정확한 IC 구성 추론 안 함" : "SOURCE_RECEIVED · 관계 비교, 정확한 픽셀 정합 아님"}
>
<CompactFacilityConnection
className={`facility-applied facility-applied--${config.routeMode}`}
routeMode={config.routeMode}
segmentShape={config.id === "bridge" ? "bridge" : undefined}
sourceStatus={config.id === "interchange" ? "SOURCE_PENDING" : "SOURCE_RECEIVED"}
label={`${config.label} 간략 적용 예제`}
>
<LocalAssetFacility config={config} />
</CompactFacilityConnection>
</TunnelSample>
</div>
</div>
)
}
// 연계 노선 page layout (2026-11-13): the package RoadsideReferenceDemo specimens in the same 2-column structure and the same
// single-line row labels as the other facility pages (SHORT / MEDIUM / LONG, ACTUAL SHORT / REF / LONG; the former two-line
// "휴게소 · SHORT" meta is replaced — the entity stays in the tag text and its accessible name). Widest row 64px + 16px + 450px
// LONG tag, so the 터널 1180px rule applies. Separated exists for Actual / Detail only, so it is that column's first row; at
// 2 columns the Position Map column keeps an empty first row so the steps stay on the same rows, at 1 column the empty row is
// not rendered. The 연계 / 노선 맥락 example is full width below.
const ROADSIDE_TESTS: { size: string; actual: string; entity: RoadsideEntity; label: string }[] = [
{ size: "SHORT", actual: "ACTUAL SHORT", entity: "rest-area", label: "휴게소" },
{ size: "MEDIUM", actual: "ACTUAL REF", entity: "drowsy-shelter", label: "졸음쉼터" },
{ size: "LONG", actual: "ACTUAL LONG", entity: "drowsy-shelter", label: "서해안고속도로 대산방향 졸음쉼터" },
]
function RoadsideSpecimens() {
return (
<div className="sb-center-tunnel sb-center-roadside">
<div className="facility-reference roadside-reference" data-reference-facility="roadside">
<article className="facility-reference__sample">
<div className="facility-reference__stage sb-center-tunnel__width-stage">
<div className="sb-center-tunnel__width">
<div className="facility-reference__width-tests sb-center-tunnel__width-group sb-center-roadside__position">
<header><strong>Position Map</strong><span>위치도</span></header>
<div className="sb-center-roadside__blank" aria-hidden="true" />
{ROADSIDE_TESTS.map((test) => (
<div key={test.size} data-roadside-test={test.size.toLowerCase()}>
<span>{test.size}</span>
{/* The tag is itself a span: the wrapper keeps the row-label rule (`> div > span`) off the tag text. */}
<div><RoadsideFacilityTag entity={test.entity} label={test.label} /></div>
</div>
))}
</div>
<div className="facility-reference__width-tests sb-center-tunnel__width-group">
<header><strong>Actual / Detail</strong><span>실사화</span></header>
<div data-roadside-actual-test="separated">
<span>SEPARATED</span>
<RoadsideFacilityTag entity="drowsy-shelter" variant="actualDetail" separated />
</div>
{ROADSIDE_TESTS.map((test) => (
<div key={test.size} data-roadside-actual-test={test.size.toLowerCase()}>
<span>{test.actual}</span>
<RoadsideFacilityTag entity={test.entity} label={test.label} variant="actualDetail" />
</div>
))}
</div>
</div>
</div>
</article>
<TunnelSample title="연계 / 노선 맥락" note="노선 + 앵커 + 연결선 + 시설물 태그">
<CompactFacilityConnection className="facility-applied facility-applied--anchored roadside-applied" routeMode="anchored" sourceStatus="SOURCE_PENDING" label="졸음쉼터 연계 노선 간략 적용 예제">
<RoadsideFacilityTag entity="drowsy-shelter" />
</CompactFacilityConnection>
</TunnelSample>
</div>
</div>
)
}
// `name` is repeated as a literal because Storybook's static indexer cannot read it through page(); it drives the sidebar label.
export const Tunnel: Story = { ...page("터널", TunnelReferenceDemo), name: "터널" }
export const Bridge: Story = { ...page("교량", BridgeReferenceDemo), name: "교량" }
export const Intersection: Story = { ...page("교차로", IntersectionReferenceDemo), name: "교차로" }
export const Junction: Story = { ...page("분기점", JunctionReferenceDemo), name: "분기점" }
export const Interchange: Story = { ...page("나들목", InterchangeReferenceDemo), name: "나들목" }
export const Roadside: Story = { ...page("휴게소 · 졸음쉼터", RoadsideReferenceDemo), name: "휴게소 · 졸음쉼터" }
export const Tunnel: Story = { ...page("터널", TunnelSpecimens), name: "터널" }
export const Bridge: Story = { ...page("교량", () => <FacilitySpecimens config={FACILITY_CONFIGS.bridge} compareAt="1210" />), name: "교량" }
export const Intersection: Story = { ...page("교차로", () => <FacilitySpecimens config={FACILITY_CONFIGS.intersection} />), name: "교차로" }
export const Junction: Story = { ...page("분기점", () => <FacilitySpecimens config={FACILITY_CONFIGS.junction} />), name: "분기점" }
export const Interchange: Story = { ...page("나들목", () => <FacilitySpecimens config={FACILITY_CONFIGS.interchange} />), name: "나들목" }
export const Roadside: Story = { ...page("휴게소 · 졸음쉼터", RoadsideSpecimens), name: "연계 노선" }
// Linked facilities are a standalone section heading followed by item cards.
// Keep the reference data/artwork and road-class grouping owned by the package.
// 2026-11-13: same `sb-page-indent` start as the other facility pages; the page class scopes the Linked-only CSS
// (hidden context notice / READY count / duplicate card name, card title and POSITION · ACTUAL label style).
export const Linked: Story = {
...page("연계 시설물", LinkedFacilityReferenceDemo),
name: "연계 시설물",
render: () => (
<StoryPage className="sb-center-linked-page">
<StoryPageHeader title="연계 시설물" />
<div className="sb-page-indent">
{withRepresentation(() => <LinkedFacilityReferenceDemo />)}
</div>
</StoryPage>
),
}
+4 -3
View File
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { CenterInformationDisplayReferenceDemo } from "@hm-design/center-graphic/reference"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { withRepresentation } from "./center-shared"
// Owner: ui-viz docs/patterns/center-information-presentation.md and center-information-{box,table,emphasis}.md.
@@ -19,13 +19,14 @@ export const Information: Story = {
render: () => (
<StoryPage>
<StoryPageHeader title="정보" />
<StorySection title="개요">
{/* 2026-11-13: no visible "개요" section title; indent kept via the common `sb-page-indent`. */}
<div className="sb-page-indent">
{withRepresentation(() => (
<div className="sb-center-information">
<CenterInformationDisplayReferenceDemo />
</div>
))}
</StorySection>
</div>
</StoryPage>
),
}
+5 -3
View File
@@ -10,8 +10,10 @@ export const CENTER_BOUNDARY =
export const SCALE_NOTE =
"제작 대상이 7680 × 2160이면 CENTER 그래픽을 100%, 3840 × 2160이면 75%로 표시합니다. Storybook 화면 크기는 배율을 정하지 않으며, 다른 제작 해상도의 배율은 정해지지 않았습니다. 제작 대상은 주소에 centerResolution=3840x2160 을 붙여 바꿉니다."
export const CenterFrame = ({ children }: { children: ReactNode }) => (
<div className="sb-center" lang="ko">
// `plain` drops the review card frames on 노선도 pages only. OrgChart previews reuse `.sb-center` and the card
// classes directly, so the plain layout is a CENTER-page modifier rather than a change to the shared frame.
export const CenterFrame = ({ children, plain = false }: { children: ReactNode; plain?: boolean }) => (
<div className={plain ? "sb-center sb-center--plain" : "sb-center"} lang="ko">
{children}
</div>
)
@@ -23,7 +25,7 @@ export const centerTargetResolution = (): CenterTargetResolution => {
}
export const withRepresentation = (Story: () => ReactNode) => (
<CenterFrame>
<CenterFrame plain>
<CenterRepresentation targetResolution={centerTargetResolution()}>
<Story />
</CenterRepresentation>
+4 -3
View File
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { RouteLine } from "@hm-design/center-graphic"
import { CenterSpatialInformationReferenceDemo } from "@hm-design/center-graphic/reference"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { withRepresentation } from "./center-shared"
// Owners: ui-viz docs/visualization/route.md, spatial-map.md, spatial-section.md, composition.md.
@@ -21,9 +21,10 @@ export const Spatial: Story = {
render: () => (
<StoryPage className="sb-center-spatial-page">
<StoryPageHeader title="노선·공간" />
<StorySection title="개요">
{/* 2026-11-13: no visible "개요" section title; indent kept via the common `sb-page-indent`. */}
<div className="sb-page-indent">
{withRepresentation(() => <CenterSpatialInformationReferenceDemo />)}
</StorySection>
</div>
</StoryPage>
),
}
+370 -16
View File
@@ -166,22 +166,8 @@
gap: var(--sb-center-section-gap);
overflow-wrap: anywhere;
}
.sb-center .linked-facility__card {
container: sb-linked-card / inline-size;
}
/* 56px label + 10px gap + measured 251px artwork + 48px common padding.
Stack the label when that row cannot fit; preserve artwork size and local scroll. */
@container sb-linked-card (max-width: 365px) {
.sb-center .linked-facility__variant {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto;
justify-items: safe center;
gap: var(--sb-center-header-gap);
}
.sb-center .linked-facility__variant > small {
grid-row: auto;
}
}
/* 2026-11-13: the former narrow-card label stacking (`sb-linked-card` container) is replaced by the 연계 시설물
card layout at the end of this file (POSITION | ACTUAL columns, `sb-linked-list` container). */
.sb-center .sb-center-status { min-width: 720px; }
/* Spatial cards reuse the common frame above. Contrast belongs to the inner
@@ -249,3 +235,371 @@
grid-template-columns: minmax(0, 1fr);
}
}
/* 노선도 pages only (CenterFrame `plain`, 2026-11-12): no review card frame.
Each specimen is a plain heading + description followed by the artwork; wider
gaps replace the card edges. The shared frame above stays for OrgChart previews,
which reuse `.sb-center` and these card classes. Artwork, the spatial-section
#efefef canvas and the review-caption fixes above are unchanged. */
.sb-center.sb-center--plain :is(.tunnel-reference__sample, .facility-reference__sample,
.center-title-reference__sample, .cid-demo__block, .linked-facility__card, .csi-panel) {
background: transparent;
border: 0;
border-radius: 0;
}
.sb-center.sb-center--plain :is(.tunnel-reference__sample, .facility-reference__sample,
.center-title-reference__sample, .cid-demo__block, .linked-facility__card) > header,
.sb-center.sb-center--plain .csi-panel > h4 {
min-height: 0;
gap: 4px;
margin: 0;
padding: 0;
background: transparent;
border-bottom: 0;
}
.sb-center.sb-center--plain .linked-facility__variant + .linked-facility__variant {
border-top: 0;
}
.sb-center.sb-center--plain .csi-panel {
padding: 0;
}
/* 노선도 layout rhythm (2026-11-12): the common StorySection / StateBoard rules of
story-page.css and story-board.tsx (Button, Badge …), applied to the package demo DOM.
- 2026-11-13: no visible "개요" section; the Story wraps the specimens in the common `sb-page-indent`
(16px / 12px at 640px and below). 연계 시설물 keeps its own unindented page.
- Specimen title row (사업명 page rule, 2026-11-13): title 16/24/700 primary, description or size
12/18 secondary 8px to the right on the same row (wraps only when the row runs out),
artwork 12px below, specimens 24px apart, columns 24px apart. Package text (Source / status) is kept.
- Artwork starts on the label's left edge; wide artwork keeps its local scroll.
- Linked road-class groups read as sections (16/24/700, 12px to content, 28px apart)
with their card grid indented, columns from a 320px minimum (StateBoard default).
Artwork size, colours and internal composition are untouched. */
.sb-center.sb-center--plain,
.sb-center-linked-page .sb-center.sb-center--plain {
padding: 0;
}
/* Common specimen title row (2026-11-13, from the 사업명 page): package demo headers are
`header > strong + span`, the 사업명 Story header is `header > h3 + span`. Overrides the package's
space-between / narrow-screen column header on 노선도 pages only. */
.sb-center.sb-center--plain :is(.tunnel-reference__sample, .facility-reference__sample,
.center-title-reference__sample, .cid-demo__block, .sb-center-tunnel__width-group) > header {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
justify-content: flex-start;
column-gap: 8px;
row-gap: 0;
}
.sb-center.sb-center--plain :is(.tunnel-reference__sample, .facility-reference__sample,
.center-title-reference__sample, .cid-demo__block, .sb-center-tunnel__width-group) > header :is(strong, h3),
.sb-center.sb-center--plain .csi-panel > h4 {
margin: 0;
color: var(--hm-sem-fg-primary);
font-size: var(--sb-page-section-title-font-size, 16px);
line-height: var(--sb-page-section-title-line-height, 24px);
font-weight: var(--hm-font-weight-bold);
}
.sb-center.sb-center--plain :is(.tunnel-reference__sample, .facility-reference__sample,
.center-title-reference__sample, .cid-demo__block, .sb-center-tunnel__width-group) > header :is(span, small, p) {
color: var(--hm-sem-fg-secondary);
font-size: 12px;
line-height: 18px;
}
/* Exceptions kept as before: 연계 시설물 item cards (label 14/20/500) and the 노선·공간 long
descriptions below their title (legend / note paragraphs, 14/20 secondary). */
.sb-center.sb-center--plain .linked-facility__card > header :is(strong, h3) {
color: var(--hm-sem-fg-secondary);
font-size: var(--hm-label-font-size);
line-height: var(--hm-label-line-height);
font-weight: var(--hm-font-weight-medium);
}
.sb-center.sb-center--plain .linked-facility__card > header :is(span, small, p),
.sb-center.sb-center--plain .csi-panel > :is(.csi-legend, .csi-note) {
color: var(--hm-sem-fg-secondary);
font-size: var(--hm-label-font-size);
line-height: var(--hm-label-line-height);
}
/* Representation note ("표시 배율 · 제작 대상"): its own row on the content's right edge
(flex end, wraps instead of overflowing). The 사업명 page shows it in its first title row instead. */
.sb-center.sb-center--plain .center-representation__note {
display: flex;
justify-content: flex-end;
text-align: right;
overflow-wrap: anywhere;
}
/* csi-panel is a 12px grid: pull the description up to 4px under its label. */
.sb-center.sb-center--plain .csi-panel > h4 + .csi-legend {
margin-top: -8px;
}
.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage,
.center-title-reference__stage, .cid-demo__stage, .linked-facility__variant) {
padding: 12px 0 0;
}
/* The demo stages' minimum heights centred the artwork in an empty frame (card era);
without the card they only push the artwork away from its label, so the stage
now follows the artwork's own height and starts at its top-left. */
.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage) {
min-height: 0;
align-items: start;
justify-items: start;
}
.sb-center.sb-center--plain :is(.center-title-reference__stage, .cid-demo__stage) {
min-height: 0;
align-items: flex-start;
justify-content: flex-start;
}
.sb-center.sb-center--plain :is(.tunnel-reference__row, .facility-reference__row) {
justify-content: flex-start;
}
.sb-center.sb-center--plain .linked-facility__variant {
min-height: 0;
justify-items: start;
}
.sb-center.sb-center--plain :is(.tunnel-reference, .facility-reference, .center-title-reference,
.cid-demo, .csi-demo) {
gap: 24px;
}
.sb-center.sb-center--plain :is(.tunnel-reference__variant-grid, .facility-reference__variant-grid) {
gap: 24px;
}
/* 2026-11-13 (시설물 layout): road-class sections 24px apart, the 노선도 section gap. */
.sb-center.sb-center--plain .linked-facility {
gap: 24px;
}
/* 2026-11-13: header → card grid is the header's own 12px padding, like the common section title (story-page.css). */
.sb-center.sb-center--plain .linked-facility__group {
gap: 0;
}
/* 2026-11-13 (시설물 layout): common title row — description 12px secondary 8px right of the title (wraps when needed). */
.sb-center.sb-center--plain .linked-facility__group > header {
justify-content: flex-start;
align-items: baseline;
column-gap: 8px;
row-gap: 0;
padding-bottom: 12px;
/* 2026-11-13: no divider, matching the common section title (story-page.css). */
}
.sb-center.sb-center--plain .linked-facility__group > header h3 {
color: var(--hm-sem-fg-primary);
font-size: var(--sb-page-section-title-font-size, 16px);
line-height: var(--sb-page-section-title-line-height, 24px);
font-weight: var(--hm-font-weight-bold);
}
.sb-center.sb-center--plain .linked-facility__group > header span {
color: var(--hm-sem-fg-secondary);
font-size: 12px;
line-height: 18px;
}
.sb-center.sb-center--plain .linked-facility__grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
gap: 24px;
padding-left: var(--sb-page-indent, 16px);
}
/* 연계 시설물 page only (2026-11-13, StoryPage `sb-center-linked-page`). The package demo is unchanged; hidden here are only
repeated texts: the top context notice (two phrases + grey bar, a layout note rather than a specimen), the road-class
"N READY 시설물 집합" count (the cards themselves show it) and the card's first name (터널 / 교량 / 교차로 repeat the Korean
name, JC / IC are printed on the artwork). The Korean name is the card title, shown once. The page uses `sb-page-indent`, so
the card grid drops its own indent: titles and cards start on the same line as the other facility pages.
Card layout (2026-11-13, replaces the 320px auto-fit grid): each card is title + two columns, POSITION | ACTUAL (the
package's two `__variant` blocks, label 11px / 700 / .08em), each column label → full artwork 8px → split artwork 16px; the
columns share their rows (subgrid), so full and split artwork line up. Widths measured on all 17 cards: widest POSITION
240px, widest ACTUAL 252px → a 2-column card needs 240 + 24 + 252 = 516px, two cards per row 516 × 2 + 24 = 1056px. The
switches follow the list width (`sb-linked-list` container): ≥ 1056px 2 cards per row, below 1 card; ≥ 516px POSITION |
ACTUAL, below stacked (24px apart). Odd counts leave the last card on the left, no filler. No card frame (plain 노선도).
Artwork, split pieces, the 12px title gap, the 24px section / card gaps and the 8px local scrollbar are unchanged. */
.sb-center-linked-page :is(.linked-facility__context, .linked-facility__group > header > span, .linked-facility__card > header > strong) {
display: none;
}
.sb-center-linked-page .sb-center.sb-center--plain .linked-facility {
container: sb-linked-list / inline-size;
}
.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__grid {
grid-template-columns: minmax(0, 1fr);
gap: 24px;
padding-left: 0;
}
.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__card {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-content: start;
gap: 12px;
}
.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__card > header > span {
color: var(--hm-sem-fg-primary);
font-size: var(--hm-label-font-size);
line-height: var(--hm-label-line-height);
font-weight: var(--hm-font-weight-bold);
text-align: left;
}
/* One column: label, full artwork, split artwork. Overrides the package's 56px label column / 1fr 1fr rows. The stage keeps
its overflow-x: auto, so artwork wider than the column scrolls locally with the 8px bar. */
.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__variant {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto;
row-gap: 16px;
column-gap: 0;
align-items: start;
justify-items: start;
padding: 0;
}
.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__variant + .linked-facility__variant {
margin-top: 12px; /* card gap 12px + 12px = 24px between the stacked POSITION and ACTUAL blocks */
}
.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__variant > small {
grid-row: auto;
margin-bottom: -8px; /* 16px row gap - 8px = label → full artwork 8px */
font-size: 11px;
}
@container sb-linked-list (min-width: 516px) {
.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__card {
grid-template-columns: repeat(2, minmax(max-content, 1fr));
grid-template-rows: repeat(4, auto);
column-gap: 24px;
}
.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__card > header {
grid-column: 1 / -1;
}
.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__variant {
grid-row: 2 / span 3;
grid-template-rows: subgrid;
}
.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__variant + .linked-facility__variant {
margin-top: 0;
}
}
@container sb-linked-list (min-width: 1056px) {
.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* 사업명 page only (2026-11-13, StoryPage className): the representation note ("표시 배율 · 제작 대상")
is shown in the first specimen's title row (.sb-center-title-note, center-title.stories.tsx), so the
package's separate note block above the specimens is hidden here. The note keeps the size text style
(12px secondary, common title row rule) and sits on the row's right edge (margin-left: auto in the
wrapping flex row, i.e. space-between for "name + size" | note); when the row runs out of room only
the note wraps to the next line, still right-aligned. No absolute positioning. */
.sb-center-title-page .sb-center.sb-center--plain .center-representation__note {
display: none;
}
.sb-center.sb-center--plain .center-title-reference__sample > header.sb-center-title-row .sb-center-title-note {
margin-left: auto;
text-align: right;
}
/* 2026-11-13: graphic local scroll cue (노선도 pages only). CENTER artwork keeps its real size, so wide
specimens scroll horizontally inside their stage; with overlay scrollbars that read as clipping.
These stages get a visible 8px horizontal scrollbar, which only appears when the stage actually
overflows (overflow-x: auto is unchanged). Thumb colours reuse the HM Scroll Area tokens.
Chromium/WebKit use ::-webkit-scrollbar (forces a classic bar); others get the standard thin bar.
Artwork size, the 12px title gap and the 24px specimen gap are untouched. */
.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage,
.center-title-reference__stage, .cid-demo__stage, .linked-facility__variant, .csi-panel > div)::-webkit-scrollbar {
height: 8px;
background: transparent;
}
.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage,
.center-title-reference__stage, .cid-demo__stage, .linked-facility__variant, .csi-panel > div)::-webkit-scrollbar-thumb {
border-radius: 9999px;
background: var(--hm-scroll-area-thumb-background, var(--hm-sem-stroke-default));
}
.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage,
.center-title-reference__stage, .cid-demo__stage, .linked-facility__variant, .csi-panel > div)::-webkit-scrollbar-thumb:hover {
background: var(--hm-scroll-area-thumb-hover-background, var(--hm-sem-fg-secondary));
}
@supports not selector(::-webkit-scrollbar) {
.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage,
.center-title-reference__stage, .cid-demo__stage, .linked-facility__variant, .csi-panel > div) {
scrollbar-width: thin;
scrollbar-color: var(--hm-scroll-area-thumb-background, var(--hm-sem-stroke-default)) transparent;
}
}
/* 터널 page layout (2026-11-13, center-facility.stories.tsx `TunnelSpecimens`). The breakpoint follows the
preview content width (container query). One width-behaviour section: Position Map | Actual / Detail
columns from 1180px (the widest row: 64px label + 16px + 497px artwork, twice, plus the 24px gap),
1 column below. Each column is title + SEPARATED + SHORT / MEDIUM / LONG; the two groups share the
parent rows (subgrid), so each step lines up with its Actual counterpart.
Artwork size, the package row label style, the 12px title gap, the 24px specimen gap and the 8px
local scrollbar are unchanged; narrow stages keep their horizontal scroll. */
.sb-center-tunnel {
container-type: inline-size;
min-width: 0;
}
.sb-center-tunnel__width {
display: grid;
grid-template-columns: minmax(max-content, 1fr);
grid-auto-rows: auto;
column-gap: 24px;
row-gap: 16px;
width: 100%;
}
.sb-center.sb-center--plain .sb-center-tunnel__width > .sb-center-tunnel__width-group {
display: grid;
grid-row: span 5;
grid-template-rows: subgrid;
gap: inherit;
width: auto;
}
.sb-center-tunnel__width-group + .sb-center-tunnel__width-group {
margin-top: 8px; /* 16px row gap + 8px = the 24px specimen gap between the stacked groups */
}
/* 2026-11-13: no "너비 동작" title row. Each column starts with the common specimen title row
(`header > strong + span`, rule above: 16/24/700 + 12px note), so both titles share row 1 and start on
the same line. The stage loses its 12px title gap (no title above it); title → first row stays 12px. */
.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage).sb-center-tunnel__width-stage {
padding-top: 0;
}
.sb-center.sb-center--plain .sb-center-tunnel__width-group > header {
margin-bottom: -4px; /* 16px row gap - 4px = the common 12px title → artwork gap */
}
/* 연계 노선: Position Map has no Separated row. Its empty first row is shown only at 2 columns (1180px block below: row
alignment with the Actual / Detail SEPARATED row); at 1 column it is not rendered and the group spans 4 rows.
Kept before the breakpoint blocks so their `span 5` wins at 2 columns (same specificity, source order). */
.sb-center-roadside__position > .sb-center-roadside__blank {
display: none; /* outranks the package row rule `.facility-reference__width-tests > div { display: grid }` */
}
.sb-center.sb-center--plain .sb-center-tunnel__width > .sb-center-tunnel__width-group.sb-center-roadside__position {
grid-row: span 4;
}
/* 교량 · 교차로 · 분기점 · 나들목 · 연계 노선 (2026-11-13) reuse this layout. A page whose widest row does not fit at 1180px
sets `data-sb-compare-at` (교량 1210px) and switches in its own block below; the rule set is the same. */
@container (min-width: 1180px) {
.sb-center-tunnel:not([data-sb-compare-at]) .sb-center-tunnel__width {
grid-template-columns: repeat(2, minmax(max-content, 1fr));
}
.sb-center.sb-center--plain .sb-center-tunnel:not([data-sb-compare-at]) .sb-center-tunnel__width > .sb-center-tunnel__width-group {
grid-row: 1 / span 5;
}
.sb-center-tunnel:not([data-sb-compare-at]) .sb-center-tunnel__width-group + .sb-center-tunnel__width-group {
margin-top: 0;
}
.sb-center-tunnel:not([data-sb-compare-at]) .sb-center-roadside__blank {
display: block;
}
}
@container (min-width: 1210px) {
.sb-center-tunnel[data-sb-compare-at="1210"] .sb-center-tunnel__width {
grid-template-columns: repeat(2, minmax(max-content, 1fr));
}
.sb-center.sb-center--plain .sb-center-tunnel[data-sb-compare-at="1210"] .sb-center-tunnel__width > .sb-center-tunnel__width-group {
grid-row: 1 / span 5;
}
.sb-center-tunnel[data-sb-compare-at="1210"] .sb-center-tunnel__width-group + .sb-center-tunnel__width-group {
margin-top: 0;
}
}
/* 노선·공간 page only (2026-11-13, StoryPage className `sb-center-spatial-page`): example-first layout.
The package demo's descriptions (.csi-legend), KNOWN_DEVIATION notes (.csi-note) and the representation
note ("표시 배율 · 제작 대상") are hidden here; the package source is unchanged. With them gone the
existing rhythm applies as-is: title → label/artwork 12px (--sb-center-header-gap), specimens 24px apart.
The section 2 sample controls (.csi-controls, user request 2026-11-13) are hidden too, so the examples
show the demo's default 기준 (Source 표본) content. Section titles, specimen labels and artwork stay visible. */
.sb-center-spatial-page .sb-center.sb-center--plain :is(.csi-panel > .csi-legend, .csi-panel > .csi-note,
.csi-panel > .csi-controls, .center-representation__note) {
display: none;
}
+55 -7
View File
@@ -1,8 +1,54 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { CenterTitle, CENTER_TITLE_COMPOSITIONS, type CenterTitleComposition } from "@hm-design/center-graphic"
import { CenterTitleReferenceDemo } from "@hm-design/center-graphic/reference"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { withRepresentation } from "./center-shared"
import type { ComponentProps } from "react"
import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { centerTargetResolution, withRepresentation } from "./center-shared"
// 사업명 page specimens (2026-11-13): composed here from the public API instead of the package reference demo, so the
// page shows "composition name + size" on one row without the Type / Source viewport labels. The package demo
// (`@hm-design/center-graphic/reference`) is unchanged; the stage class keeps the existing artwork backdrop.
const TITLE_SPECIMENS: { key: string; title: string; meta: string; props: ComponentProps<typeof CenterTitle> }[] = [
...(Object.entries(CENTER_TITLE_COMPOSITIONS) as [CenterTitleComposition, (typeof CENTER_TITLE_COMPOSITIONS)[CenterTitleComposition]][]).map(
([composition, specimen]) => ({
key: composition,
title: specimen.label,
meta: `${specimen.sourceViewport.width} × ${specimen.sourceViewport.height}`,
props: { composition },
}),
),
{
key: "long",
title: "노선구분 + 사업명 (긴 콘텐츠)",
meta: "같은 연결과 고정 외곽 지오메트리; 콘텐츠 기반 너비",
props: { composition: "routeProjectName", projectName: "서해안고속도로 대산연결 제2구간 장기 건설사업 기본 및 실시설계" },
},
]
// 2026-11-13: the representation note moves into the first specimen's title row (right side). The package note above
// the specimens is hidden on this page only (center-stories.css); its text follows the same target resolution here.
function representationNote() {
const isCompact = centerTargetResolution() === "3840x2160"
return `표시 배율 ${isCompact ? 75 : 100}% · 제작 대상 ${isCompact ? "3840" : "7680"} × 2160`
}
function TitleSpecimens() {
return (
<div className="center-title-reference" data-reference-component="center-title">
{TITLE_SPECIMENS.map(({ key, title, meta, props }, index) => (
<article className="center-title-reference__sample" key={key} data-content-test={key === "long" ? "long" : undefined}>
<header className="sb-center-title-row">
<h3>{title}</h3>
<span>{meta}</span>
{index === 0 ? <span className="sb-center-title-note">{representationNote()}</span> : null}
</header>
<div className="center-title-reference__stage">
<CenterTitle {...props} />
</div>
</article>
))}
</div>
)
}
// Owner: ui-viz docs/components/center-title.md. Title Resolution B numeric output is TBD; the per-Type palette is SCOPED_DESIGN_APPROVAL.
const meta = {
@@ -20,11 +66,13 @@ export const Compositions: Story = {
name: "사업명",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
// 2026-11-13: no visible "개요" section title. The specimens keep their former indent (common `sb-page-indent`),
// and the representation note sits on the first title row's right edge (TitleSpecimens, `sb-center-title-page`).
<StoryPage className="sb-center-title-page">
<StoryPageHeader title="사업명" />
<StorySection title="개요">
{withRepresentation(() => <CenterTitleReferenceDemo />)}
</StorySection>
<div className="sb-page-indent">
{withRepresentation(() => <TitleSpecimens />)}
</div>
</StoryPage>
),
}
+6 -6
View File
@@ -22,22 +22,22 @@ export const Area: Story = {
<ChartSampleNotice />
{/* Same grid and order as Line Chart, then a Stacked | Expanded row. */}
<StorySectionGrid className={chartGrid.pairs}>
<ChartSpecimen title="Curve" description="monotone">
<ChartSpecimen title="Curve">
<HMAreaChart {...sampleLineCompare} curve="monotone" label="월별 접수·완료 곡선형 영역 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Curve · Dot + Label" description="monotone · Dot과 값 Label">
<ChartSpecimen title="Curve · Dot + Label">
<HMAreaChart {...sampleLineCompare} curve="monotone" pointLabels label="월별 접수·완료 곡선형 Dot·값 Label 영역 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Straight" description="linear">
<ChartSpecimen title="Straight">
<HMAreaChart {...sampleLineCompare} curve="linear" label="월별 접수·완료 직선형 영역 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Straight · Color Dot + Label" description="linear · Dot 개별 색상 + 값 Label">
<ChartSpecimen title="Straight · Color Dot + Label">
<HMAreaChart {...sampleLinePointColors} curve="linear" pointLabels label="월별 접수·완료 직선형 포인트별 색상 영역 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Step" description="step">
<ChartSpecimen title="Step">
<HMAreaChart {...sampleLineCompare} curve="step" label="월별 접수·완료 계단형 영역 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Step · Dot + Label" description="step · Dot과 값 Label">
<ChartSpecimen title="Step · Dot + Label">
<HMAreaChart {...sampleLineCompare} curve="step" pointLabels label="월별 접수·완료 계단형 Dot·값 Label 영역 차트(예시 데이터)" />
</ChartSpecimen>
</StorySectionGrid>
+1 -1
View File
@@ -26,7 +26,7 @@ export const Bar: Story = {
<ChartSpecimen title="Grouped" description="월별 접수·완료 현황 · 상반기 월별 건수">
<HMBarChart {...sampleBar} label="월별 접수·완료 건수 막대 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Horizontal" description="항목별 처리 건수 · 가로 막대">
<ChartSpecimen title="Horizontal" description="항목별 처리 건수">
<HMHorizontalBarChart {...sampleHorizontalBar} label="항목별 처리 건수 가로 막대 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Stacked" description="월별 접수·완료 현황(누적)">
+1 -1
View File
@@ -4,7 +4,7 @@ import { StorySection } from "@/components/story-page"
// Storybook presentation only (not part of @hm-design/chart): the open Chart page layout (2026-11-12, user request).
// Charts sit directly on the Storybook content background, without the example card (background, border, padding). Each chart
// is one StorySection: H2 variant name → description (what the example shows) → thin section divider → optional toolbar → chart.
// is one StorySection: H2 variant name → optional description (what the example shows) → 12px → optional toolbar → chart.
// The charts themselves are the public @hm-design/chart components on the @hm-design/chart/examples sample data, with the same
// props as the matching *Example card, so data, legend, tooltip and interaction are unchanged. No common layout change; the page
// only sets the chart's own surface token (chartPageClass).
+4 -4
View File
@@ -56,17 +56,17 @@ export const Donut: Story = {
<ChartSampleNotice />
{/* Two per row from 1024px, three from 1200px, one below; source order is the mobile order. */}
<StorySectionGrid className={chartGrid.compact}>
<ChartSpecimen title="Basic" description="기본" toolbar="중앙 표시 없음">
<ChartSpecimen title="Basic" toolbar="중앙 표시 없음">
<HMDonutChart data={sampleDonutStates.data} label="업무 상태 도넛 차트, 가운데 값 없음(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Basic · Center Text" description="전체 합계" toolbar="중앙 표시: 전체 합계">
<ChartSpecimen title="Basic · Center Text" toolbar="중앙 표시: 전체 합계">
<HMDonutChart data={sampleDonutStates.data} totalLabel={sampleDonutStates.totalLabel} label="업무 상태 도넛 차트, 가운데 전체 합계(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Active" description="선택된 항목" toolbar={`선택 항목: ${selected?.label ?? ""}`}>
<ChartSpecimen title="Active" toolbar={`선택 항목: ${selected?.label ?? ""}`}>
<HMDonutChart data={sampleDonutStates.data} activeId={sampleDonutStates.activeId} label="업무 상태 도넛 차트, 선택된 항목 강조(예시 데이터)" />
</ChartSpecimen>
<DonutInteractive />
<ChartSpecimen title="Solid" description="입체형" toolbar={null}>
<ChartSpecimen title="Solid" toolbar={null}>
<HMSolidDonutChart data={sampleDonutStates.data} totalLabel={sampleDonutStates.totalLabel} label="업무 상태 입체형 도넛 차트, 가운데 전체 합계(예시 데이터)" />
</ChartSpecimen>
</StorySectionGrid>
+6 -6
View File
@@ -23,22 +23,22 @@ export const Line: Story = {
{/* Source order is the mobile order. 1024px: Curve | Curve + labels per row. 1200px: three columns, plain styles in the
first row and their Dot + Label versions below. */}
<StorySectionGrid className={chartGrid.pairs}>
<ChartSpecimen title="Curve" description="monotone · Dot 없음">
<ChartSpecimen title="Curve">
<HMLineChart {...sampleLineCompare} curve="monotone" dots={false} label="월별 접수·완료 곡선형 선 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Curve · Dot + Label" description="monotone · Dot과 값 Label">
<ChartSpecimen title="Curve · Dot + Label">
<HMLineChart {...sampleLineCompare} curve="monotone" pointLabels label="월별 접수·완료 곡선형 Dot·값 라벨 선 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Straight" description="linear · Dot 없음">
<ChartSpecimen title="Straight">
<HMLineChart {...sampleLineCompare} curve="linear" dots={false} label="월별 접수·완료 직선형 선 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Straight · Color Dot + Label" description="linear · Dot 개별 색상 + 값 Label">
<ChartSpecimen title="Straight · Color Dot + Label">
<HMLineChart {...sampleLinePointColors} curve="linear" pointLabels label="월별 접수·완료 직선형 포인트별 색상 선 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Step" description="step · Dot 없음">
<ChartSpecimen title="Step">
<HMLineChart {...sampleLineCompare} curve="step" dots={false} label="월별 접수·완료 계단형 선 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Step · Dot + Label" description="step · Dot과 값 Label">
<ChartSpecimen title="Step · Dot + Label">
<HMLineChart {...sampleLineCompare} curve="step" pointLabels label="월별 접수·완료 계단형 Dot·값 라벨 선 차트(예시 데이터)" />
</ChartSpecimen>
</StorySectionGrid>
+4 -4
View File
@@ -21,16 +21,16 @@ export const Radar: Story = {
<ChartSampleNotice />
{/* Two per row from 1024px, all four in one row from 1600px, one below. */}
<StorySectionGrid className={chartGrid.quad}>
<ChartSpecimen title="Basic" description="영역">
<ChartSpecimen title="Basic">
<HMRadarChart {...sampleRadar} label="단계별 처리 수준 방사형 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Dot" description="축별 Dot">
<ChartSpecimen title="Dot">
<HMRadarChart {...sampleRadar} dots label="단계별 처리 수준 방사형 차트, Dot(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Lines Only" description="선만">
<ChartSpecimen title="Lines Only">
<HMRadarChart {...sampleRadar} filled={false} label="단계별 처리 수준 방사형 차트, 선만(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Custom Label" description="축 Label">
<ChartSpecimen title="Custom Label">
<HMRadarChart {...sampleRadar} customLabels label="단계별 처리 수준 방사형 차트, 축 Label(예시 데이터)" />
</ChartSpecimen>
</StorySectionGrid>
+6 -6
View File
@@ -21,22 +21,22 @@ export const Radial: Story = {
<ChartSampleNotice />
{/* Two per row from 1024px, three from 1200px, one below; source order is the mobile order. */}
<StorySectionGrid className={chartGrid.compact}>
<ChartSpecimen title="Basic" description="radial-simple · 항목별 막대">
<ChartSpecimen title="Basic" description="항목별 막대">
<HMRadialBarChart {...sampleRadial} label="업무 상태 방사형 막대 차트(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Label" description="radial-label · 막대 안 항목명">
<ChartSpecimen title="Label" description="막대 안 항목명">
<HMRadialBarChart {...sampleRadial} labels startAngle={-90} endAngle={380} label="업무 상태 방사형 막대 차트, 항목명 Label(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Grid" description="radial-grid · 원형 Grid">
<ChartSpecimen title="Grid" description="원형 Grid">
<HMRadialBarChart {...sampleRadial} grid label="업무 상태 방사형 막대 차트, Grid(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Text" description="radial-text · 중앙 Text">
<ChartSpecimen title="Text" description="중앙 Text">
<HMRadialMeterChart {...sampleRadialText} startAngle={0} endAngle={250} innerRadius={75} outerRadius={90} trackRadii={[88.5, 76.5]} cornerRadius={10} label="진행 중 건수 방사형 차트, 중앙 Text(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Shape" description="radial-shape · 링 트랙">
<ChartSpecimen title="Shape" description="링 트랙">
<HMRadialMeterChart {...sampleRadialShape} startAngle={0} endAngle={100} innerRadius={50} outerRadius={95} trackRadii={[90.5, 54.5]} label="진행 중 건수 방사형 차트, Shape(예시 데이터)" />
</ChartSpecimen>
<ChartSpecimen title="Stacked" description="radial-stacked · 반원 누적">
<ChartSpecimen title="Stacked" description="반원 누적">
<HMRadialStackedChart {...sampleRadialStacked} label="업무 상태 방사형 Stacked 차트(예시 데이터)" />
</ChartSpecimen>
</StorySectionGrid>
+24 -3
View File
@@ -57,7 +57,16 @@ export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKi
if (kind === 'Logo') {
component = createLogoLegend(document, context, controller.signal);
// Overview already supplies the section heading; keep the shared logo list intact.
if (hideLogoHeading) component.querySelector(':scope > h2')?.remove();
if (hideLogoHeading) {
component.querySelector(':scope > h2')?.remove();
// Without the heading, the list's UA top margin (1em = 13px) would sit under the Storybook section title
// (2026-11-13: title → content stays the common 12px). Package CSS is unchanged.
const list = component.querySelector<HTMLElement>(':scope > .hm-orgchart-logo-list');
if (list) list.style.marginTop = '0';
// 2026-11-13: 가족사 현황 page shows the 7 logo items without a card border. Padding, size and the
// grid gap stay, so items remain separated by space only. Package CSS is unchanged.
list?.querySelectorAll<HTMLElement>(':scope > li').forEach((item) => { item.style.border = '0'; });
}
}
else if (kind === 'DepartmentColors') component = createDepartmentColors(document, context, controller.signal);
else if (kind === 'PositionSummary') component = presentation === 'workdesk' ? createOrganizationTotals(document, context) : createPositionSummary(document, context);
@@ -89,7 +98,9 @@ export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKi
if (compactDepartmentPreview) {
// Keep the representative and department cards built by the shared Core.
// The Tunnel review frame styles wrappers only; approved artwork stays intact.
shell.classList.add('sb-center', 'hm-orgchart-department-preview');
// 2026-11-13: `sb-center--plain` drops the review card frame (grey panel, border, header strip),
// like the 노선도 pages; the OrgChart artwork itself is untouched.
shell.classList.add('sb-center', 'sb-center--plain', 'hm-orgchart-department-preview');
const representative = component.querySelector<HTMLElement>(':scope > .hm-orgchart-representative-preview');
if (representative) {
const card = representative.querySelector<HTMLElement>('[data-card-kind="topLeader"]')!;
@@ -139,7 +150,17 @@ export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKi
preview.append(header, stage);
return preview;
});
component.replaceChildren(...(representative ? [representative] : []), ...rows, ...statusPreviews);
// 2026-11-13: the six department rows form their own titled section (like 대표자/파견자/휴직자),
// keeping their 3-column grid inside it.
const departments = document.createElement('section');
departments.className = 'tunnel-reference__sample hm-orgchart-departments-section';
const departmentsHeader = document.createElement('header');
const departmentsTitle = document.createElement('h2'); departmentsTitle.textContent = '부서별 색상';
departmentsHeader.append(departmentsTitle);
const departmentRows = document.createElement('div'); departmentRows.className = 'hm-orgchart-department-rows';
departmentRows.append(...rows);
departments.append(departmentsHeader, departmentRows);
component.replaceChildren(...(representative ? [representative] : []), departments, ...statusPreviews);
component.classList.add('hm-orgchart-department-colors-design', 'tunnel-reference__variant-grid');
for (const name of component.querySelectorAll<HTMLElement>('.hm-orgchart-person-name')) name.setAttribute('aria-label', sampleName);
}
+36 -11
View File
@@ -1,8 +1,24 @@
/* Department Colors Storybook adapter only. The actual Tunnel sample/stage
frame comes from center-stories.css; this sheet never selects employee cards. */
.hm-orgchart-department-preview .hm-orgchart-department-colors-design {
/* Department Colors Storybook adapter only. The specimen wrappers reuse the
center-stories.css classes in their `sb-center--plain` form (no grey panel,
border or header strip); this sheet never selects employee cards. */
/* 2026-11-13: Button/Badge page rhythm. Four sections (대표자 / 부서별 색상 / 파견자 / 휴직자) sit
the common section gap apart; 파견자·휴직자 share a row on desktop, columns 24px apart. */
.sb-center.hm-orgchart-department-preview .hm-orgchart-department-colors-design {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sb-center-column-gap);
gap: var(--sb-page-section-gap, 28px) 24px;
}
.hm-orgchart-department-preview .hm-orgchart-departments-section {
grid-column: 1 / -1;
}
/* The six department rows keep their 3-column grid, 24px apart, 12px under the section title. */
.hm-orgchart-department-preview .hm-orgchart-department-rows {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px;
padding-top: 12px;
}
.hm-orgchart-department-preview .hm-orgchart-color-preview {
@@ -14,8 +30,17 @@
grid-column: 1 / -1;
}
/* Section titles: the common StorySection title (16/24/700, primary); department bars are not selected. */
.sb-center.hm-orgchart-department-preview :is(.hm-orgchart-representative-preview, .hm-orgchart-departments-section, .hm-orgchart-status-preview) > header > h2 {
margin: 0;
color: var(--hm-sem-fg-primary);
font-size: var(--sb-page-section-title-font-size, 16px);
line-height: var(--sb-page-section-title-line-height, 24px);
font-weight: var(--hm-font-weight-bold, 700);
}
/* Group titles are OrgChart artwork. Preserve their established typography,
white text and organization gradient within the common review header. */
white text and organization gradient; the bar spans its column. */
.hm-orgchart-department-preview .hm-orgchart-color-preview > header > .hm-orgchart-color-department-name {
box-sizing: border-box;
width: 100%;
@@ -25,10 +50,11 @@
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
}
/* Specimens start on the label's left edge; wide content keeps the stage's local scroll. */
.hm-orgchart-department-preview .hm-orgchart-department-stage {
display: grid;
justify-items: safe center;
align-items: center;
justify-items: start;
align-items: start;
}
.hm-orgchart-department-preview .hm-orgchart-preview-grid {
@@ -36,21 +62,20 @@
gap: var(--sb-center-column-gap);
}
/* Keep Fixed's status presenter inside the existing review surface, without
introducing the full canvas background or coordinates into the specimen. */
/* Keep Fixed's status presenter without the full canvas background or coordinates. */
.hm-orgchart-department-preview .hm-orgchart-status-preview-composition {
width: max-content;
background: transparent;
}
@media (max-width: 960px) {
.hm-orgchart-department-preview .hm-orgchart-department-colors-design {
.sb-center.hm-orgchart-department-preview :is(.hm-orgchart-department-colors-design, .hm-orgchart-department-rows) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.hm-orgchart-department-preview .hm-orgchart-department-colors-design {
.sb-center.hm-orgchart-department-preview :is(.hm-orgchart-department-colors-design, .hm-orgchart-department-rows) {
grid-template-columns: minmax(0, 1fr);
}
}
+11 -7
View File
@@ -1,15 +1,19 @@
/* Existing Expanded Story content layout; HM widget styles stay unchanged. */
/* Reuse the existing Storybook review card surface through inherited inputs.
Only the outer Auto preview consumes these; its controls/artwork stay intact. */
/* 2026-11-13: no review card surface (grey panel, border, radius) around the Auto preview; these
inputs only reach the outer Auto container, so the canvas, controls and artwork stay intact. */
.hm-orgchart-story-expanded .hm-orgchart-story-preview.sb-center {
--hm-orgchart-expanded-preview-background: var(--sb-center-card-background);
--hm-orgchart-expanded-preview-border: var(--sb-center-card-border);
--hm-orgchart-expanded-preview-radius: var(--sb-center-card-radius);
--hm-orgchart-expanded-preview-background: transparent;
--hm-orgchart-expanded-preview-border: 0;
--hm-orgchart-expanded-preview-radius: 0;
padding: 0;
}
/* Preview first: the chart follows the page header with the header's own 24px rhythm. */
/* 확장 조직도 page only: no page gutters, so the chart uses the whole preview (like 전체 구성). */
.sb-page.hm-orgchart-story-expanded { padding-block: 0; }
.sb-page.hm-orgchart-story-expanded > .sb-page-inner { width: 100%; margin-inline: 0; }
/* Preview first: the chart is the page's first visible block (the page title is the visually hidden H1), so it starts at the
page's top gutter. 2026-11-13: the former margin-top: 24px was removed; the common first-block reset (story-page.css) already
overrode it (computed 0px), so nothing moves. */
.hm-orgchart-story-expanded .hm-orgchart-story-lead {
display: grid;
gap: 0.75rem;
margin-top: 24px;
}
+8 -3
View File
@@ -25,8 +25,13 @@ const interactions: OrgChartInteractionOptions = { collapse: true, zoom: true, p
// The elements stay in the shadow DOM: the move feedback remains the cards' aria-describedby / live status
// (visually hidden), and the OrgChart browser checks still read their text.
const previewText = new CSSStyleSheet();
// 2026-11-13: also hides the chart title and lets the canvas fill the preview height below the toolbar
// (Core's fixed 65vh canvas height and outer padding are overridden here only); toolbar keeps an 8px safe inset.
previewText.replaceSync(`
.hm-orgchart-date, .hm-orgchart-expanded-selection { display: none; }
.hm-orgchart-date, .hm-orgchart-expanded-selection, .hm-orgchart-expanded > .hm-orgchart-heading { display: none; }
.hm-orgchart-expanded { height: 100vh; grid-template-rows: auto minmax(0, 1fr); gap: 0; padding: 0; }
.hm-orgchart-expanded > .hm-orgchart-expanded-toolbar { padding: 8px; }
.hm-orgchart-expanded > .hm-orgchart-viewport { height: auto; min-height: 0; }
.hm-orgchart-move-feedback { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
`);
function hidePreviewText(container: HTMLElement): () => void {
@@ -51,11 +56,11 @@ function ExpandedPreview() {
return <StoryPage className="hm-orgchart-story-page hm-orgchart-story-expanded">
<StoryPageHeader title="확장 조직도" />
{/* Preview first: the chart sits right under the page title; its own header already names it, so no section header. */}
{/* Preview first: the chart is the first visible block (the page title is the hidden H1); its own header already names it, so no section header. */}
<div className="hm-orgchart-story hm-orgchart-story-lead">
{error ? <p role="alert" className="hm-orgchart-story-note">{error}</p> : null}
<div ref={preview} className="hm-orgchart-story-preview sb-center">
<OrgChart dataSource={source} stylesheetUrls={compositionStylesheets} displayMode="readOnly" layoutMode="auto" autoLayout={layout} interactions={interactions} dragDropEnabled={false} selectedCompanyIds={allCompanies} companyTopNodes={topNodes} employeePopup="hm-dialog" className="hm-orgchart-story-chart" onLifecycleError={cause => setError(cause instanceof Error ? cause.message : '조직도 설정을 적용할 수 없습니다.')} onStateChange={state => { if (state.status === 'error') setError(state.issues.map(issue => issue.message).join(' / ')); }} />
<OrgChart dataSource={source} stylesheetUrls={compositionStylesheets} displayMode="readOnly" layoutMode="auto" initialView="fit" autoLayout={layout} interactions={interactions} dragDropEnabled={false} selectedCompanyIds={allCompanies} companyTopNodes={topNodes} employeePopup="hm-dialog" className="hm-orgchart-story-chart" onLifecycleError={cause => setError(cause instanceof Error ? cause.message : '조직도 설정을 적용할 수 없습니다.')} onStateChange={state => { if (state.status === 'error') setError(state.issues.map(issue => issue.message).join(' / ')); }} />
</div>
</div>
</StoryPage>;
+2 -2
View File
@@ -2,7 +2,7 @@ import { StrictMode } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { OverviewPreview } from './org-chart-page-previews';
const meta = { title: '조직도/개요', component: OverviewPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof OverviewPreview>;
const meta = { title: '조직도/가족사 현황', component: OverviewPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof OverviewPreview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const LogoPersonnelSummary: Story = { name: '개요', parameters: { __id: 'orgchart--logo-personnel-summary' } };
export const LogoPersonnelSummary: Story = { name: '가족사 현황', parameters: { __id: 'orgchart--logo-personnel-summary' } };
+6 -8
View File
@@ -18,35 +18,33 @@ const compositionStylesheets = { engine: engineUrl, core: coreUrl };
export function CompositionPreview() {
const host = useRef<HTMLElement>(null);
useLayoutEffect(() => centerCompositionPreview(host.current!), []);
return <StoryPage className="hm-orgchart-story-page">
// 2026-11-13: no section title and no page gutters; Core's own width/height fit fills the preview.
return <StoryPage className="hm-orgchart-story-page hm-orgchart-composition-page">
<StoryPageHeader title="전체 구성" />
<StorySection title="고정형 조직도">
<section ref={host} className="hm-orgchart-story hm-orgchart-story-full" aria-label="Full Composition">
<OrgChart dataSource={compositionSource} stylesheetUrls={compositionStylesheets} displayMode="readOnly" className="hm-orgchart-story-chart" />
</section>
</StorySection>
</StoryPage>;
}
export function DepartmentTypographyPreview() {
return <StoryPage className="hm-orgchart-story-page">
<StoryPageHeader title="부서별 색상" />
<StorySection title="조직별 컬러 · 카드 미리보기">
{/* 2026-11-13: no shared section title; the preview renders its own four section titles. */}
<div className="hm-orgchart-story-preview"><CoreComponentPreview kind="DepartmentColors" presentation="design" /></div>
</StorySection>
</StoryPage>;
}
export function OverviewPreview() {
return <StoryPage className="hm-orgchart-story-page">
<StoryPageHeader title="개요" />
<StorySection title="관계사·회사 로고">
<StoryPageHeader title="가족사 현황" />
<StorySection title="가족사 로고">
<div className="hm-orgchart-story-preview"><CoreComponentPreview kind="Logo" hideLogoHeading /></div>
</StorySection>
<StorySection title="조직·직위별 인원 현황">
<div className="hm-orgchart-story-preview"><CoreComponentPreview kind="PositionSummary" presentation="workdesk" /></div>
</StorySection>
<StorySection title="관계사별 인원 현황">
<StorySection title="가족사별 인원 현황">
<div className="hm-orgchart-story-preview"><CoreComponentPreview kind="AffiliationSummary" presentation="workdesk" /></div>
</StorySection>
</StoryPage>;
+5
View File
@@ -17,6 +17,11 @@
.hm-orgchart-story-page .hm-orgchart-story { max-width: none; }
.hm-orgchart-story-page .hm-orgchart-story-preview { min-width: 0; max-width: 100%; }
/* 전체 구성 page only (2026-11-13): no page gutters, so Core's proportional
width/height fit uses the whole preview. Other StoryPage layouts are unchanged. */
.sb-page.hm-orgchart-composition-page { padding-block: 0; }
.sb-page.hm-orgchart-composition-page > .sb-page-inner { width: 100%; margin-inline: 0; }
.hm-orgchart-story-note {
margin: 0;
overflow-wrap: anywhere;
+3 -4
View File
@@ -37,11 +37,10 @@
.sw-footer-story-row--tight { gap: 0; }
.sw-footer-story-inline { display: inline-flex; flex-direction: column; padding: 0; }
/* Story page frame only (2026-11-11): the width rows sit in a container (.sw-story-responsive), where margins do not collapse,
so the rows own the common section gap (story-page.css) and their sections drop their own top margin. */
so their sections drop their own top margin. 2026-11-13: the container is also .sw-story-stack (sw-stories.css, as on the
SW Header / Viewer / Step Header pages), whose flex gap (the common section gap) spaces the rows; hidden rows
(sw-story-responsive-hidden, rows wider than the Story column) leave no gap, so the former sibling margins are gone. */
.sb-page .sw-footer-story-width > .sb-page-section { margin-top: 0; }
.sw-footer-story-width + .sw-footer-story-width { margin-top: var(--sb-page-section-gap, 24px); }
/* The hidden 1920px row (Responsive More, narrower than 1920px) takes its gap with it. */
@container sw-story-page (max-width: 2039.98px) { .sw-story-responsive-hidden + .sw-footer-story-width { margin-top: 0; } }
.sw-footer-story-aux-compare { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 16px; }
.sw-footer-story-aux-row { display: flex; align-items: center; gap: 12px; margin: 0; }
.sw-footer-story-aux-row figcaption { flex: 0 0 120px; font-size: 12px; color: #3d4a45; }
+13 -4
View File
@@ -35,6 +35,7 @@ type Story = StoryObj<typeof meta>
function Screen({ width = 1920, canvas = 160, children }: { width?: number | string; canvas?: number; children: ReactNode }) {
// 2026-10-22: always fluid (no inner scroller). `width` is a maximum, so the screen background and the footer share
// the available width, commands that do not fit go to ▲ More, and an open menu is never clipped.
// 2026-11-13: in the narrowest 화면 너비 row, sw-stories.css keeps the row width and scrolls it in a narrower Story column.
return (
<div className="sw-footer-story-screen" style={{ width: "100%", maxWidth: width }} data-width={width}>
<div className="sw-footer-story-canvas" style={{ height: canvas }} aria-label="도면 영역 (예시)" />
@@ -78,14 +79,18 @@ function Workspace({ width = "100%" }: { width?: number | string }) {
}
export const FullFooter: Story = {
name: "SW 하단 명령어바",
name: "SW 명령어바",
render: () => (
<StoryPage>
<StoryPageHeader title="SW 하단 명령어바"description="사용자 제공 실제 화면(2026-10-20) 구성: 도면 영역 + SWFooter(좌표 status, 바 안의 명령창 command, 오른쪽 명령 칸, Layout 탭). 모든 상태는 호스트(Story)가 Props로 소유하고, 컴포넌트는 Callback만 보냅니다." />
{/* 2026-10-22 (user request): no section heading; the live composition follows the page title. Behaviour: command
click = select + history, category tabs, empty slot guide, aux toggles, ▲ = command window, Layout tabs, Enter runs. */}
<section className="sb-page-section" aria-label="실제 Footer 조합 (동작)">
{/* 2026-11-13: below 760px (the narrowest 화면 너비 row) the composition keeps 760px and scrolls inside this section
(sw-stories.css .sw-story-fit) instead of widening the page; the footer's own minimum widths are unchanged. */}
<section className="sb-page-section sw-story-fit" data-sw-fit-min={760} aria-label="실제 Footer 조합 (동작)">
<div className="sw-story-fit__scroll">
<Workspace width={1920} />
</div>
</section>
</StoryPage>
),
@@ -386,12 +391,16 @@ export const Responsive: Story = {
<StoryPage>
<StoryPageHeader title="SW 하단 명령어바 — 화면 너비"description="칸 영역은 바 폭으로만 정해지고 분류의 명령 수와 무관합니다(분류를 바꿔도 위치 고정). Figma에는 좁은 폭 정의가 없어 사용자 제공 실제 화면(2026-10-20)을 따릅니다: 빈 칸(+)이 먼저 줄고, 그래도 넘치는 명령은 마지막 칸의 ▲(ico_arrow)로 칸 위 패널에 펼침(▼ = 열림, Escape·바깥 클릭 닫힘). 분류 탭이 넘치면 ◀ ▶로 스크롤. 바 폭 1279px 이하에서 보조기능은 아이콘만(45 × 33). 바 안 명령창은 최소 280px." />
{/* Same 1920px row rule as SW Header / SW Viewer Responsive More (sw-stories.css .sw-story-responsive). */}
<div className="sw-story-responsive">
<div className="sw-story-stack sw-story-responsive">
{[1920, 1440, 1280, 950, 860, 760].map(width => (
// 2026-10-23: the 1920 row stays inside the Story column (as SW Viewer › Responsive) and is hidden on narrow pages.
<div key={width} className={`sw-footer-story-width${width === 1920 ? " sw-story-responsive-hidden" : ""}`}>
// 2026-11-13: rows wider than the Story column are hidden and the narrowest row (760) keeps its width and scrolls
// (sw-stories.css data-sw-width-row / data-sw-width-min), so every shown row is drawn at its title's width.
<div key={width} className={`sw-footer-story-width${width === 1920 ? " sw-story-responsive-hidden" : ""}`} data-sw-width-row={width} data-sw-width-min={width === 760 ? width : undefined}>
<StorySection title={`${width}px`}>
<div className="sw-story-fit__scroll">
<Screen width={width} canvas={90}><ReferenceFooter /></Screen>
</div>
</StorySection>
</div>
))}
+12 -5
View File
@@ -790,18 +790,25 @@ export const ResponsiveMore: Story = {
<StoryPage>
<StoryPageHeader title="SW Header — 화면 너비" />
<div className="sw-story-stack sw-story-responsive">
{MORE_WIDTHS.map(width => (
{MORE_WIDTHS.map(width => {
// 2026-11-13: rows wider than the Story column are hidden and the narrowest row keeps its width and scrolls
// (sw-stories.css data-sw-width-row / data-sw-width-min), so every shown row is drawn at its title's width.
const min = width === Math.min(...MORE_WIDTHS)
return (
// 2026-10-23: the 1920 row stays inside the Story column (as SW Viewer › Responsive) and is hidden on narrow pages.
<div key={width} className={width === 1920 ? "sw-story-responsive-hidden" : undefined}>
<div key={width} className={width === 1920 ? "sw-story-responsive-hidden" : undefined} data-sw-width-row={width} data-sw-width-min={min ? width : undefined}>
<StorySection title={`${width}px`}>
{/* Same structure as the Logo page (no black frame). This page only shows widths, so the five field dropdowns are
held closed with the public controlled props; the open callbacks and header data are unchanged. */}
<div className="sw-story-fit__scroll">
<div style={{ width: "100%", maxWidth: width }} data-frame-width={width}>
<StyleHeader layerMenuOpen={false} colorMenuOpen={false} lineStyleMenuOpen={false} textStyleMenuOpen={false} dimensionStyleMenuOpen={false} />
</div>
</div>
</StorySection>
</div>
))}
)
})}
</div>
</StoryPage>
),
@@ -934,8 +941,8 @@ function PropertyCompare() {
<p hidden data-property-log="">{log.join(" → ")}</p>
<div className="sw-story-compare-scroll" data-compare-scroll="">
<div className="sw-story-compare sw-story-compare--grid">
{PROPERTY_STATES.map(state => <figure key={state.example} className="sw-story-compare__cell" data-compare={state.example}>
<figcaption><strong>{state.title}</strong></figcaption>
{/* 2026-11-13: no visible caption — the panel title (선택된 객체 - …) already names the state; the figure keeps it as its accessible name. */}
{PROPERTY_STATES.map(state => <figure key={state.example} className="sw-story-compare__cell" data-compare={state.example} aria-label={state.title}>
<PropertyState example={state.example} onLog={line => setLog(lines => [...lines.slice(-7), `${state.example}: ${line}`])} />
</figure>)}
</div>
+10 -3
View File
@@ -170,19 +170,26 @@ export const Responsive: Story = {
<StoryPage>
<StoryPageHeader title="SW Step Header — 화면 너비" />
<div className="sw-story-stack sw-story-responsive">
{WIDTHS.map(width => (
{WIDTHS.map(width => {
// 2026-11-13: rows wider than the Story column are hidden and the narrowest row (1100) keeps its width and scrolls
// (sw-stories.css data-sw-width-row / data-sw-width-min), so every shown row is drawn at its title's width.
const min = width === Math.min(...WIDTHS)
return (
// The 1920 row shows only on a page with 1920px of room, like the SW Header / Viewer Responsive pages.
<div key={width} className={width === 1920 ? "sw-story-responsive-hidden" : undefined}>
<div key={width} className={width === 1920 ? "sw-story-responsive-hidden" : undefined} data-sw-width-row={width} data-sw-width-min={min ? width : undefined}>
<StorySection title={`${width}px`}>
<div className="sw-story-fit__scroll">
<Row max={width}>
<div className="sw-story-stack" data-width={width}>
<StaticHeader />
<MultiHeader step="body-plan" documentTitle="Example project" />
</div>
</Row>
</div>
</StorySection>
</div>
))}
)
})}
</div>
</StoryPage>
),
+50 -12
View File
@@ -1,13 +1,47 @@
/* Story canvas only: frames that simulate a header width. Never styles the header itself. */
.sw-story-scroller { max-width: 100%; overflow-x: auto; }
/* Responsive More pages: the 1920px row (sw-story-responsive-hidden) shows when the page has 1920px of usable width —
the page (container sw-story-page, not the browser) minus the Inner's 60px side margins (story-page.css,
60px from a 768px viewport), so 2040px (2026-11-05; was 2000px with the former 40px gutters); with less room the whole row —
the page (container sw-story-page, not the browser) minus the Inner's 30px side margins (story-page.css,
30px from a 768px viewport), so 1980px (2026-11-13; was 2040px with 60px margins, 2000px with the former 40px gutters); with less room the whole row —
title, header, background, margin — is hidden. 2026-10-23: it stays inside the Story column like every other row (the
former reach-out of the 80vw Inner, sw-story-responsive-wide, is gone); its preview is at most 1920px wide. */
.sw-story-responsive { container-type: inline-size; }
.sw-story-responsive { container: sw-story-rows / inline-size; }
.sb-page:has(.sw-story-responsive) { container: sw-story-page / inline-size; }
@container sw-story-page (max-width: 2039.98px) { .sw-story-responsive-hidden { display: none; } }
@container sw-story-page (max-width: 1979.98px) { .sw-story-responsive-hidden { display: none; } }
/* 2026-11-13 (user request): a width row is drawn at the width its title names. A row (data-sw-width-row) shows only where the
Story column (container sw-story-rows) is at least that wide; the page's narrowest row (data-sw-width-min) always shows and,
in a narrower column, keeps its own width (min-width on the frame inside .sw-story-fit__scroll) and scrolls there.
Each page keeps its own width set (the components' own breakpoints). min-width and overflow apply only below that width, so
open menus are not clipped where the row fits and a row can still be narrowed in place (e.g. the viewer More checks). */
@container sw-story-rows (max-width: 1439.98px) { [data-sw-width-row="1440"]:not([data-sw-width-min]) { display: none; } }
@container sw-story-rows (max-width: 1365.98px) { [data-sw-width-row="1366"]:not([data-sw-width-min]) { display: none; } }
@container sw-story-rows (max-width: 1279.98px) { [data-sw-width-row="1280"]:not([data-sw-width-min]) { display: none; } }
@container sw-story-rows (max-width: 949.98px) { [data-sw-width-row="950"]:not([data-sw-width-min]) { display: none; } }
@container sw-story-rows (max-width: 859.98px) { [data-sw-width-row="860"]:not([data-sw-width-min]) { display: none; } }
@container sw-story-rows (max-width: 759.98px) { [data-sw-width-row="760"]:not([data-sw-width-min]) { display: none; } }
@container sw-story-rows (max-width: 1099.98px) {
[data-sw-width-min="1100"] .sw-story-fit__scroll { overflow-x: auto; }
[data-sw-width-min="1100"] .sw-story-fit__scroll > * { min-width: 1100px; }
}
@container sw-story-rows (max-width: 759.98px) {
[data-sw-width-min="760"] .sw-story-fit__scroll { overflow-x: auto; }
[data-sw-width-min="760"] .sw-story-fit__scroll > * { min-width: 760px; }
}
@container sw-story-rows (max-width: 519.98px) {
[data-sw-width-min="520"] .sw-story-fit__scroll { overflow-x: auto; }
[data-sw-width-min="520"] .sw-story-fit__scroll > * { min-width: 520px; }
}
/* The same rule for a single live example (SW 명령어바 760px, Viewer 로고 520px — the narrowest width each page set supports):
the example keeps that width and scrolls inside the Story column only when the column is narrower. */
.sw-story-fit { container: sw-story-fit / inline-size; }
@container sw-story-fit (max-width: 759.98px) {
[data-sw-fit-min="760"] .sw-story-fit__scroll { overflow-x: auto; }
[data-sw-fit-min="760"] .sw-story-fit__scroll > * { min-width: 760px; }
}
@container sw-story-fit (max-width: 519.98px) {
[data-sw-fit-min="520"] .sw-story-fit__scroll { overflow-x: auto; }
[data-sw-fit-min="520"] .sw-story-fit__scroll > * { min-width: 520px; }
}
.sw-story-frame { background: #060606; }
/* Shared drawing-area canvas of the SW Stories (2026-10-19): the SW/Viewer black grid — 40px cells, 1px lines of
HM green #266651 at 18%, on #0A0A0B. Used behind the SW Header work area and dropdown room and the viewer frames. */
@@ -32,6 +66,9 @@
story-page.css, so the 8px stack gap and each section's own top margin do not add up. Headers inside are unchanged. */
.sb-page .sw-story-stack:has(> .sb-page-section, > div > .sb-page-section) { gap: var(--sb-page-section-gap); margin-top: var(--sb-page-section-gap); }
.sb-page .sw-story-stack > .sb-page-section, .sb-page .sw-story-stack > div > .sb-page-section { margin-top: 0; }
/* 2026-11-13: SW Viewer 화면 너비 has a hidden callback log (<p hidden>) between the page H1 and the stack, so the common
first-block reset (story-page.css) misses the stack. Only a stack right after the H1 and one hidden element starts at the top. */
.sb-page > .sb-page-inner > .sb-page-a11y-title:first-child + [hidden] + .sw-story-stack { margin-top: 0; }
/* Window Move Motion: window controls alone on the window-area surface, magnified for comparison. */
.sw-story-window { display: flex; width: 152px; height: 48px; background: var(--hm-sw-header-document-background); }
.sw-story-zoom { zoom: 3; width: max-content; }
@@ -48,22 +85,23 @@
/* Property panel 4-state comparison at the panels' own size (314px wide, never shrunk).
2026-10-26 (user request): 3 columns — the short panels (none, all) stacked in column 1, the long ones (circle,
expanded) side by side in columns 2 and 3. Rows are auto / 1fr so the long panels' height goes to row 2 only and
"all" sits 24px under "none". Narrower screens: 2 columns × 2 rows, and one column below 787px; no Inner scroller
and no sideways scroll (2026-10-22). 2026-11-05: each step needs its columns plus the Inner's 60px side margins
(120, story-page.css) and a ~15px vertical scrollbar (the media queries read the viewport): 3 columns from 1125px
(3 × 314 + 2 × 24 = 990), 2 columns from 787px (2 × 314 + 24 = 652). Before: 1080px / 720px with 16–40px gutters.
Both steps sit above 768px, where the margins are 60px. The single column always fits: the margins are 16px up to
480px and grow to 60px by 768px (story-page.css), so 375 / 390px screens have 343 / 358px for the 314px panel. */
"all" sits 24px under "none". Narrower screens: 2 columns × 2 rows, and one column below 727px; no Inner scroller
and no sideways scroll (2026-10-22). Each step needs its columns plus the Inner's side margins (story-page.css) and a
~15px vertical scrollbar (the media queries read the viewport). 2026-11-13 (30px margins, 60 in all): 3 columns from
1065px (3 × 314 + 2 × 24 = 990), 2 columns from 727px (2 × 314 + 24 = 652; the margins are still ~28px there, 652 + 56 + 15
= 723). Before: 1125px / 787px with 60px margins (2026-11-05), 1080px / 720px with 16–40px gutters. The single column
always fits: the margins are 16px up to 480px and grow to 30px by 768px, so 375 / 390px screens have 343 / 358px for
the 314px panel. */
.sw-story-compare-page .sw-story-log { margin-top: 12px; }
.sw-story-compare.sw-story-compare--grid { display: grid; grid-template-columns: repeat(2, max-content); align-items: start; gap: 24px; }
@media (min-width: 1125px) {
@media (min-width: 1065px) {
.sw-story-compare.sw-story-compare--grid { grid-template-columns: repeat(3, max-content); grid-template-rows: auto 1fr; }
.sw-story-compare--grid > [data-compare="none"] { grid-area: 1 / 1; }
.sw-story-compare--grid > [data-compare="all"] { grid-area: 2 / 1; }
.sw-story-compare--grid > [data-compare="circle"] { grid-area: 1 / 2 / span 2; }
.sw-story-compare--grid > [data-compare="expanded"] { grid-area: 1 / 3 / span 2; }
}
@media (max-width: 786.98px) { .sw-story-compare.sw-story-compare--grid { grid-template-columns: max-content; } }
@media (max-width: 726.98px) { .sw-story-compare.sw-story-compare--grid { grid-template-columns: max-content; } }
/* Placement-only exception, same as the Overview Stage: the product clamps a panel to the viewport
height; here the scroller provides the room so the 754px panel keeps its size. */
.sw-story-compare--grid .hm-sw-property { max-height: none; }
+2 -2
View File
@@ -211,8 +211,8 @@ export function PropertiesPanel({ demo, selected = true, ...placement }: { demo:
headerActions={selected ? <SWViewerButton variant="ghost" aria-label="선택 확대" title="선택 확대" onClick={() => demo.record("zoomToSelection (미연결)")}><ZoomIn size={16} /></SWViewerButton> : undefined}>
{selected ? <>
<SWViewerBadge className="sw-viewer-story-props-id">{ENTITY_ID}</SWViewerBadge>
<SWViewerSection title="일반 정보 (General)">{rows(ENTITY_ROWS)}</SWViewerSection>
<SWViewerSection title="기하 속성 (Geometry)">{rows(GEOMETRY_ROWS)}</SWViewerSection>
<SWViewerSection title="일반 정보">{rows(ENTITY_ROWS)}</SWViewerSection>
<SWViewerSection title="기하 속성">{rows(GEOMETRY_ROWS)}</SWViewerSection>
</> : (
<div className="sw-viewer-story-empty"><MousePointerClick size={24} aria-hidden="true" />도면에서 객체(엔티티)를 클릭하면<br />상세 속성이 표시됩니다</div>
)}
+58 -16
View File
@@ -1,4 +1,4 @@
import { useRef, useState, type ReactNode } from "react"
import { useLayoutEffect, useRef, useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { withSWNotice } from "./sw-story-notice"
import {
@@ -32,11 +32,12 @@ function Log({ lines }: { lines: string[] }) {
}
/** A viewer screen: header on top, the drawing area below it (panels are placed inside this frame). */
function ViewerFrame({ width = 1440, height = 620, children, header }: { width?: number | null; height?: number; header: ReactNode; children?: ReactNode }) {
function ViewerFrame({ width = 1440, minWidth, height = 620, children, header }: { width?: number | null; minWidth?: number; height?: number; header: ReactNode; children?: ReactNode }) {
return (
<div className="sw-story-scroller">
{/* Fills the Story column up to `width` (null: the whole column), so panels placed at the right edge stay visible. */}
<div className="sw-viewer-story-frame" style={{ width: "100%", maxWidth: width ?? undefined, height }} data-viewer-frame="">
{/* Fills the Story column up to `width` (null: the whole column), so panels placed at the right edge stay visible.
`minWidth` (설정창, 2026-11-13): the frame keeps that width and the scroller scrolls in a narrower column. */}
<div className="sw-viewer-story-frame" style={{ width: "100%", maxWidth: width ?? undefined, minWidth, height }} data-viewer-frame="">
{header}
<div className="sw-viewer-story-canvas" aria-label="도면 영역 (예시)">{children}</div>
</div>
@@ -83,9 +84,8 @@ export const ViewerHeader: Story = {
{/* The minimize / close example was removed from this Story; the component keeps supporting it
(SWHeaderWindowControls at the right end of `trailing`, VIEWER.md §2.1). */}
<StoryPageHeader title="SW Viewer Header" />
<StorySection title="기본형">
{/* 2026-11-13: no "기본형" section title; the viewer is the page's first block. */}
<ViewerScreen />
</StorySection>
</StoryPage>
),
}
@@ -267,22 +267,57 @@ export const SettingsPanel: Story = {
},
}
// 설정창 layout (2026-11-13): the three popups (레이어 314 / 속성 314 / 옵션 340px wide; 420 / 420 / 520px tall) sit in one
// row from a 1076px frame (the former fixed 24 / 368 / 712 placement). In a narrower frame SWViewerPanel kept them inside the
// frame on top of each other (68px overlap at a 1024px preview), so they move to 2 columns (레이어 · 속성, then 옵션; 속성 ends
// at 682px) and, below a 700px frame, to 1 column, and the frame grows to fit. The frame keeps at least 520px (the narrowest
// viewer width) and scrolls in a narrower column, so the 340px 옵션 popup always fits (SWViewerPanel width unchanged). Storybook presentation only: the panels keep their public API; each layout
// remounts them (key) because defaultPosition is read on mount. The Story column width is measured (useLayoutEffect).
type PanelLayout = 3 | 2 | 1
type PanelPoint = { left: number; top: number }
const PANEL_LAYOUTS: Record<PanelLayout, { height: number; layers: PanelPoint; properties: PanelPoint; options: PanelPoint }> = {
3: { height: 620, layers: { left: 24, top: 16 }, properties: { left: 368, top: 16 }, options: { left: 712, top: 16 } },
2: { height: 1052, layers: { left: 24, top: 16 }, properties: { left: 368, top: 16 }, options: { left: 24, top: 452 } },
1: { height: 1488, layers: { left: 16, top: 16 }, properties: { left: 16, top: 452 }, options: { left: 16, top: 888 } },
}
function usePanelLayout() {
const ref = useRef<HTMLDivElement>(null)
const [layout, setLayout] = useState<PanelLayout>(3)
useLayoutEffect(() => {
const node = ref.current
if (!node) return
const update = () => {
const width = Math.min(node.clientWidth, 1440)
setLayout(width >= 1076 ? 3 : width >= 700 ? 2 : 1)
}
update()
const observer = new ResizeObserver(update)
observer.observe(node)
return () => observer.disconnect()
}, [])
return { ref, layout }
}
export const ContentVariants: Story = {
// 2026-10-21: the one settings-popup Story (former 설정창 and 같은 프레임, 다른 내부 콘텐츠 pages); ID kept.
// 2026-10-22 (user request): the 축척 지정 demo popup was removed.
name: "설정창",
render: () => {
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["layers", "properties", "options"] })
const { ref, layout } = usePanelLayout()
const place = PANEL_LAYOUTS[layout]
return (
<StoryPage>
<StoryPageHeader title="SW Viewer — 설정창" />
<Log lines={demo.log} />
{/* 2026-10-22: 620px fits the tallest popup (옵션, 520px) now that 축척 지정 is gone. */}
<ViewerFrame height={620} header={<SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<LayerPanel demo={demo} strategy="absolute" defaultPosition={{ left: 24, top: 16 }} />
<PropertiesPanel demo={demo} strategy="absolute" defaultPosition={{ left: 368, top: 16 }} />
<OptionsPanel demo={demo} strategy="absolute" defaultPosition={{ left: 712, top: 16 }} />
{/* 2026-10-22: 620px fits the tallest popup (옵션, 520px) in the one-row layout now that 축척 지정 is gone. */}
<div ref={ref} data-panel-layout={layout}>
<ViewerFrame height={place.height} minWidth={520} header={<SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
<LayerPanel key={`layers-${layout}`} demo={demo} strategy="absolute" defaultPosition={place.layers} />
<PropertiesPanel key={`properties-${layout}`} demo={demo} strategy="absolute" defaultPosition={place.properties} />
<OptionsPanel key={`options-${layout}`} demo={demo} strategy="absolute" defaultPosition={place.options} />
</ViewerFrame>
</div>
</StoryPage>
)
},
@@ -328,13 +363,17 @@ export const Logo: Story = {
return (
<StoryPage>
<StoryPageHeader title="SW Viewer — 로고" />
<div className="sw-story-stack">
{/* 2026-11-13: below the narrowest viewer width (520px, the 화면 너비 page's last row) the header keeps 520px and
scrolls inside its section (sw-stories.css .sw-story-fit) instead of widening the page. */}
<div className="sw-story-stack sw-story-fit" data-sw-fit-min={520}>
{rows.map(row => (
<StorySection key={row.title} title={row.title}>
{/* 1440px is a maximum (2026-10-22): no scroller; the header fits the available width. */}
{/* 1440px is a maximum (2026-10-22): the header fits the available width down to 520px. */}
<div className="sw-story-fit__scroll">
<div style={{ width: "100%", maxWidth: 1440 }} data-logo-case={row.title}>
<SWViewerHeader {...row.props} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />
</div>
</div>
</StorySection>
))}
<Log lines={demo.log} />
@@ -357,10 +396,13 @@ export const Responsive: Story = {
// 2026-10-22 (user request): the 1920 row no longer reaches out of the content area (no sw-story-responsive-wide);
// like the other rows it is a maximum inside the Story column. 2026-10-23: it is hidden on the same narrow pages
// as the SW Header / Footer 1920 rows (sw-story-responsive-hidden, sw-stories.css), e.g. a 1920px screen.
<div key={width} className={width === 1920 ? "sw-story-responsive-hidden" : undefined}><StorySection title={`${width}px`}>
{/* The row width is a maximum (2026-10-22): on a narrower screen the header shrinks with it instead of scrolling. */}
{/* Same structure as the Logo page (no scroller / bottom padding), so the spacing matches it. */}
// 2026-11-13: rows wider than the Story column are hidden and the narrowest row (520) keeps its width and scrolls
// (sw-stories.css data-sw-width-row / data-sw-width-min), so every shown row is drawn at its title's width.
<div key={width} className={width === 1920 ? "sw-story-responsive-hidden" : undefined} data-sw-width-row={width} data-sw-width-min={width === 520 ? width : undefined}><StorySection title={`${width}px`}>
{/* Same structure as the Logo page (no black frame / bottom padding), so the spacing matches it. */}
<div className="sw-story-fit__scroll">
<div style={{ width: "100%", maxWidth: width }} data-width={width}><SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div>
</div>
</StorySection></div>
))}
</div>