Merge pull request 'Style/storybook ui refinements' (#46) from style/storybook-ui-refinements into main
Deploy Storybook / deploy (push) Successful in 1m23s

Reviewed-on: #46
This commit was merged in pull request #46.
This commit is contained in:
shkim4 committed 2026-11-13 18:00:14 +09:00
commit 0f375dc4bd
125 files changed
+3470 -1503

No files matched your search

+35 -39
View File
@@ -16,7 +16,8 @@
--overlay-black-30: rgba(0, 0, 0, .3); --overlay-black-30: rgba(0, 0, 0, .3);
} }
/* Storybook 10.6 manager only. Source: docs/patterns/dashboard-composition.md §6. /* Storybook 10.6 manager only. Source: docs/patterns/dashboard-composition.md §6.
The 18px join / inset geometry follows the current LNB specimen, not a new token. */ 2026-11-13 compact 180px LNB: 14px side gutters, 40px / 36px rows, 6px selected radius and a 12px join
(was 18px, the LNB specimen value) so the curves stay in proportion to the narrower column. Not a new token. */
@font-face { @font-face {
font-family: 'Hanmac Manager Noto Sans KR'; font-family: 'Hanmac Manager Noto Sans KR';
src: url('/fonts/NotoSansKR-variable.ttf') format('truetype'); src: url('/fonts/NotoSansKR-variable.ttf') format('truetype');
@@ -36,7 +37,7 @@
} }
#storybook-sidebar-region { #storybook-sidebar-region {
--hm-manager-join: 18px; --hm-manager-join: 12px;
color: var(--color-fg-on-green-primary); color: var(--color-fg-on-green-primary);
background: linear-gradient(90deg, background: linear-gradient(90deg,
var(--hm-green-default) 0%, var(--hm-green-light-1) 30%, var(--hm-green-default) 0%, var(--hm-green-light-1) 30%,
@@ -46,7 +47,7 @@
font-family: var(--hm-manager-font); font-family: var(--hm-manager-font);
} }
#storybook-sidebar-region [data-radix-scroll-area-content] > div { #storybook-sidebar-region [data-radix-scroll-area-content] > div {
padding: 28px 0 28px 20px; padding: 20px 0 20px 14px;
} }
/* Keep the native draggable scrollbar in the unused left gutter. The selected /* Keep the native draggable scrollbar in the unused left gutter. The selected
item must reach the content edge without a scroll thumb crossing its curves. item must reach the content edge without a scroll thumb crossing its curves.
@@ -61,13 +62,17 @@
background: var(--hm-fg-on-green-secondary); background: var(--hm-fg-on-green-secondary);
} }
#storybook-sidebar-region .sidebar-header { #storybook-sidebar-region .sidebar-header {
margin: 0 20px 0px 0; margin: 0 14px 0px 0;
padding: 0; padding: 0;
color: var(--color-fg-on-green-primary); color: var(--color-fg-on-green-primary);
} }
/* The Settings button is hidden, so the title uses the full header width. */ /* The Settings button is hidden, so the title uses the full header width. */
#storybook-sidebar-region .sidebar-header > div { margin-right: 0; } #storybook-sidebar-region .sidebar-header > div { margin-right: 0; }
#storybook-sidebar-region .sidebar-header > div a { #storybook-sidebar-region .sidebar-header > div a {
/* 2026-11-13: centred on the 180px LNB (the header box is already symmetric, 14px each side);
Storybook's -4px link inline margin is dropped so the link spans exactly that box. */
justify-content: center;
margin-inline: 0;
color: var(--color-fg-on-green-primary); color: var(--color-fg-on-green-primary);
font: 700 16px/30px var(--hm-manager-font); font: 700 16px/30px var(--hm-manager-font);
white-space: normal; white-space: normal;
@@ -113,27 +118,18 @@
#storybook-explorer-tree .sidebar-subheading { #storybook-explorer-tree .sidebar-subheading {
position: relative; position: relative;
margin: 0; margin: 0;
padding: 0 20px 0 0; padding: 0 14px 0 0;
min-height: 48px; min-height: 40px;
color: var(--hm-fg-on-green-secondary); color: var(--hm-fg-on-green-secondary);
} }
#storybook-explorer-tree .sidebar-subheading[data-hm-section]:not([data-hm-section='0']) { #storybook-explorer-tree .sidebar-subheading[data-hm-section]:not([data-hm-section='0']) {
margin-top: 24px; margin-top: 0;
}
#storybook-explorer-tree .sidebar-subheading[data-hm-section]:not([data-hm-section='0'])::before {
content: '';
position: absolute;
top: -12px;
left: 0;
right: 20px;
height: 2px;
background: linear-gradient(var(--overlay-black-30) 0 50%, rgba(255,255,255,.4) 50% 100%);
mask-image: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent);
} }
/* 2026-11-13: the divider above each group was removed. */
#storybook-explorer-tree .sidebar-subheading > button { #storybook-explorer-tree .sidebar-subheading > button {
min-height: 48px; min-height: 40px;
color: inherit; color: inherit;
font: 700 14px/28px var(--hm-manager-font); font: 700 14px/24px var(--hm-manager-font);
letter-spacing: 0; letter-spacing: 0;
text-transform: none; text-transform: none;
} }
@@ -141,7 +137,7 @@
position: relative; position: relative;
margin: 0; margin: 0;
padding: 0; padding: 0;
min-height: 48px; min-height: 40px;
overflow: visible; overflow: visible;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
@@ -155,51 +151,51 @@
box-shadow: inset 1px 0 var(--hm-green-light-2); box-shadow: inset 1px 0 var(--hm-green-light-2);
} }
#storybook-explorer-tree .sidebar-item[data-hm-group-start='true'] { #storybook-explorer-tree .sidebar-item[data-hm-group-start='true'] {
border-radius: 8px 0 0 0; border-radius: 6px 0 0 0;
box-shadow: inset 1px 1px var(--hm-green-light-2); box-shadow: inset 1px 1px var(--hm-green-light-2);
} }
#storybook-explorer-tree .sidebar-item[data-hm-group-end='true'] { #storybook-explorer-tree .sidebar-item[data-hm-group-end='true'] {
border-radius: 0 0 0 8px; border-radius: 0 0 0 6px;
padding-bottom: 8px; padding-bottom: 6px;
box-shadow: inset 1px -1px var(--hm-green-light-2); box-shadow: inset 1px -1px var(--hm-green-light-2);
} }
#storybook-explorer-tree .sidebar-item > :is(a[id], button[id]:not([data-testid])) { #storybook-explorer-tree .sidebar-item > :is(a[id], button[id]:not([data-testid])) {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
min-height: 48px; min-height: 40px;
height: auto; height: auto;
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 10px 32px 10px 18px; padding: 8px 14px 8px 10px;
border: 0; border: 0;
border-radius: 4px 0 0 4px; border-radius: 4px 0 0 4px;
background: transparent; background: transparent;
color: inherit; color: inherit;
font: 700 14px/28px var(--hm-manager-font); font: 700 14px/24px var(--hm-manager-font);
text-align: left; text-align: left;
text-decoration: none; text-decoration: none;
white-space: normal; white-space: normal;
overflow: visible; overflow: visible;
text-overflow: clip; text-overflow: clip;
} }
/* Sub rows (depth 2+): 42px (7px + 28px line + 7px), shorter than the 48px top-level rows. */ /* Sub rows (depth 2+): 36px (6px + 24px line + 6px), shorter than the 40px top-level rows (2026-11-13 compact 180px LNB). */
#storybook-explorer-tree .sidebar-item:is([data-hm-depth='2'], [data-hm-depth='3'], [data-hm-depth='4']) { #storybook-explorer-tree .sidebar-item:is([data-hm-depth='2'], [data-hm-depth='3'], [data-hm-depth='4']) {
min-height: 42px; min-height: 36px;
} }
#storybook-explorer-tree .sidebar-item:is([data-hm-depth='2'], [data-hm-depth='3'], [data-hm-depth='4']) > :is(a[id], button[id]:not([data-testid])) { #storybook-explorer-tree .sidebar-item:is([data-hm-depth='2'], [data-hm-depth='3'], [data-hm-depth='4']) > :is(a[id], button[id]:not([data-testid])) {
min-height: 42px; min-height: 36px;
padding-block: 7px; padding-block: 6px;
} }
#storybook-explorer-tree .sidebar-item[data-hm-depth='2'] > :is(a[id], button[id]:not([data-testid])) { #storybook-explorer-tree .sidebar-item[data-hm-depth='2'] > :is(a[id], button[id]:not([data-testid])) {
margin-left: 32px; margin-left: 26px;
width: calc(100% - 32px); width: calc(100% - 26px);
} }
#storybook-explorer-tree .sidebar-item[data-hm-depth='3'] > :is(a[id], button[id]:not([data-testid])), #storybook-explorer-tree .sidebar-item[data-hm-depth='3'] > :is(a[id], button[id]:not([data-testid])),
#storybook-explorer-tree .sidebar-item[data-hm-depth='4'] > :is(a[id], button[id]:not([data-testid])) { #storybook-explorer-tree .sidebar-item[data-hm-depth='4'] > :is(a[id], button[id]:not([data-testid])) {
margin-left: 44px; margin-left: 36px;
width: calc(100% - 44px); width: calc(100% - 36px);
} }
/* Menu item labels and section heading labels (.sidebar-subheading) are both 14px / 700. */ /* Menu item labels and section heading labels (.sidebar-subheading) are both 14px / 700. */
#storybook-explorer-tree .sidebar-item .hm-manager-label { #storybook-explorer-tree .sidebar-item .hm-manager-label {
@@ -246,9 +242,9 @@
display: none; display: none;
} }
/* First-depth leaves still sit inside their root label after losing the icon /* First-depth leaves still sit inside their root label after losing the icon
slot. Deeper rows retain their existing 32px / 44px depth margins. */ slot. Deeper rows retain their 26px / 36px depth margins. */
#storybook-explorer-tree .sidebar-item[data-hm-depth='1'] > :is(a[id], button[id]:not([data-testid])):has(> div:first-child > svg[type]):not(:has(> div:first-child > div > svg)) { #storybook-explorer-tree .sidebar-item[data-hm-depth='1'] > :is(a[id], button[id]:not([data-testid])):has(> div:first-child > svg[type]):not(:has(> div:first-child > div > svg)) {
padding-left: 32px; padding-left: 26px;
} }
#storybook-explorer-tree .sidebar-item[data-hm-current-parent='true'], #storybook-explorer-tree .sidebar-item[data-hm-current-parent='true'],
#storybook-explorer-tree .sidebar-subheading[data-hm-current-parent='true'] { #storybook-explorer-tree .sidebar-subheading[data-hm-current-parent='true'] {
@@ -261,7 +257,7 @@
z-index: 1; z-index: 1;
background: var(--color-bg-content); background: var(--color-bg-content);
color: var(--color-action-primary); color: var(--color-action-primary);
border-radius: 8px 0 0 8px; border-radius: 6px 0 0 6px;
} }
/* Keep the keyboard skip link without reserving a 1px flex column. /* Keep the keyboard skip link without reserving a 1px flex column.
That column exposed the green branch background at the selected item's end. */ That column exposed the green branch background at the selected item's end. */
@@ -300,7 +296,7 @@
background: radial-gradient(circle at bottom left, transparent calc(var(--hm-manager-join) - .5px), var(--color-bg-content) var(--hm-manager-join)); background: radial-gradient(circle at bottom left, transparent calc(var(--hm-manager-join) - .5px), var(--color-bg-content) var(--hm-manager-join));
} }
#storybook-explorer-tree .sidebar-item[data-selected='true'][data-hm-group-end='true']::after { #storybook-explorer-tree .sidebar-item[data-selected='true'][data-hm-group-end='true']::after {
top: calc(100% - 8px); top: calc(100% - 6px);
} }
/* Auxiliary status/create actions are separate from selected row geometry. /* Auxiliary status/create actions are separate from selected row geometry.
Hide only these controls; the Story link and native group toggles stay intact. */ Hide only these controls; the Story link and native group toggles stay intact. */
+10 -6
View File
@@ -29,6 +29,9 @@ toolbarStyle.textContent = `
document.head.appendChild(toolbarStyle) document.head.appendChild(toolbarStyle)
// Sidebar display names only. Story titles, Story IDs and storySort stay English; items not listed keep item.name. // Sidebar display names only. Story titles, Story IDs and storySort stay English; items not listed keep item.name.
// 2026-11-13 grouped pages: 버튼 (Button + Toggle), 선택 컨트롤 (Checkbox / Radio Group / Switch), 시트·드로어 (Sheet + Drawer),
// 툴팁·호버 카드 (Tooltip + Hover Card), 접기·펼치기 (Accordion + Collapsible), 날짜 선택 (Date Picker + Calendar). The absorbed
// components keep their own labels for the hidden (verification) Stories shown by the sidebar tag filter.
const koreanLabels = { const koreanLabels = {
foundation: '기본 요소', foundation: '기본 요소',
components: '컴포넌트', components: '컴포넌트',
@@ -43,6 +46,7 @@ const koreanLabels = {
'components-form-input': '입력창', 'components-form-input': '입력창',
'components-form-textarea': '텍스트 영역', 'components-form-textarea': '텍스트 영역',
'components-form-select': '선택 목록', 'components-form-select': '선택 목록',
'components-form-selection-controls': '선택 컨트롤',
'components-form-checkbox': '체크박스', 'components-form-checkbox': '체크박스',
'components-form-radio-group': '라디오 버튼', 'components-form-radio-group': '라디오 버튼',
'components-form-switch': '스위치', 'components-form-switch': '스위치',
@@ -55,12 +59,12 @@ const koreanLabels = {
'components-navigation-menubar': '메뉴바', 'components-navigation-menubar': '메뉴바',
'components-overlay-dialog': '대화상자', 'components-overlay-dialog': '대화상자',
'components-overlay-alert-dialog': '확인·경고 대화상자', 'components-overlay-alert-dialog': '확인·경고 대화상자',
'components-overlay-sheet': '시트 패널', 'components-overlay-sheet': '시트·드로어',
'components-overlay-drawer': '드로어', 'components-overlay-drawer': '드로어',
'components-overlay-dropdown-menu': '드롭다운 메뉴', 'components-overlay-dropdown-menu': '팝업 메뉴',
'components-overlay-context-menu': '컨텍스트 메뉴', 'components-overlay-context-menu': '컨텍스트 메뉴',
'components-overlay-popover': '팝오버', 'components-overlay-popover': '팝오버',
'components-overlay-tooltip': '툴팁', 'components-overlay-tooltip': '툴팁·호버 카드',
'components-overlay-hover-card': '호버 카드', 'components-overlay-hover-card': '호버 카드',
'components-data-display-card': '카드', 'components-data-display-card': '카드',
'components-data-display-table': '테이블', 'components-data-display-table': '테이블',
@@ -72,7 +76,7 @@ const koreanLabels = {
'components-data-display-aspect-ratio': '화면 비율', 'components-data-display-aspect-ratio': '화면 비율',
'components-data-display-carousel': '캐러셀', 'components-data-display-carousel': '캐러셀',
'components-data-display-scroll-area': '스크롤 영역', 'components-data-display-scroll-area': '스크롤 영역',
'components-disclosure-accordion': '아코디언', 'components-disclosure-accordion': '접기·펼치기',
'components-disclosure-collapsible': '접기·펼치기', 'components-disclosure-collapsible': '접기·펼치기',
'components-disclosure-resizable': '크기 조절', 'components-disclosure-resizable': '크기 조절',
'components-search-date-command': '명령어 검색', 'components-search-date-command': '명령어 검색',
@@ -86,9 +90,9 @@ const koreanLabels = {
'patterns-data-data-table': '데이터 테이블', 'patterns-data-data-table': '데이터 테이블',
'patterns-data-filter-bar': '필터 바', 'patterns-data-filter-bar': '필터 바',
'patterns-layout': '화면 구성', 'patterns-layout': '화면 구성',
'patterns-layout-gnb': '전체 메뉴 (GNB)', 'patterns-layout-gnb': '상단 메뉴',
'patterns-layout-dashboard': '대시보드', 'patterns-layout-dashboard': '대시보드',
'patterns-layout-lnb': '좌측 메뉴 (LNB)', 'patterns-layout-lnb': '좌측 메뉴',
'patterns-layout-dashboard-layout': '대시보드', 'patterns-layout-dashboard-layout': '대시보드',
'patterns-layout-page-header': '페이지 헤더', 'patterns-layout-page-header': '페이지 헤더',
chart: '차트', chart: '차트',
+4 -3
View File
@@ -29,7 +29,8 @@ const preview: Preview = {
"Components", "Components",
[ [
"Form", "Form",
["Button", "Input", "Textarea", "Select", "Checkbox", "Radio Group", "Switch", "Slider", "Toggle", "Toggle Group", "Label"], // "Selection Controls" (2026-11-13) takes the place of Checkbox / Radio Group / Switch, now hidden from the sidebar.
["Button", "Input", "Textarea", "Select", "Selection Controls", "Checkbox", "Radio Group", "Switch", "Slider", "Toggle", "Toggle Group", "Label"],
"Navigation", "Navigation",
["Tabs", "Breadcrumb", "Pagination", "Navigation Menu", "Menubar"], ["Tabs", "Breadcrumb", "Pagination", "Navigation Menu", "Menubar"],
"Overlay", "Overlay",
@@ -48,9 +49,9 @@ const preview: Preview = {
"Chart", "Chart",
["Bar Chart", "Line Chart", "Donut Chart", "Pie Chart", "Radar Chart", "Area Chart", "Radial Chart"], ["Bar Chart", "Line Chart", "Donut Chart", "Pie Chart", "Radar Chart", "Area Chart", "Radial Chart"],
"SW", "SW",
["Header", ["SW Header", "*"], "Viewer", "Step Header", "Popup", "하단 명령어바", ["SW 하단 명령어바", "*"], "Theme"], ["Header", ["SW Header", "*"], "Viewer", "Step Header", "Popup", "하단 명령어바", ["SW 명령어바", "*"], "Theme"],
"조직도", "조직도",
["개요", "부서별 색상", "전체 구성", "확장 조직도"], ["가족사 현황", "부서별 색상", "전체 구성", "확장 조직도"],
"노선도", "노선도",
"*", "*",
// With includeNames, keeps the Loader group after the Runtime Contract Stories (its place without names). // With includeNames, keeps the Loader group after the Runtime Contract Stories (its place without names).
+16 -7
View File
@@ -1,19 +1,28 @@
// Pins :hover and :active visuals for state Stories (Storybook only, never loaded by the app). // Pins :hover, :active and :focus-visible visuals for state Stories (Storybook only, never loaded by the app).
// Every rule that uses :hover or :active is cloned right after itself with the pseudo-class swapped for a // Every rule that uses :hover, :active or :focus-visible is cloned right after itself with the pseudo-class swapped for a
// data attribute, so cascade order and specificity stay the same. No declaration or value is authored here. // data attribute, so cascade order and specificity stay the same. No declaration or value is authored here.
// 2026-11-13: :focus-visible → data-sb-focus, so a Focus specimen can show its ring without holding the page focus (only one
// element can be focused; e.g. Selection Controls focuses the Checkbox and pins the Radio Group / Switch rings).
const HOVER = "data-sb-hover" const HOVER = "data-sb-hover"
const ACTIVE = "data-sb-active" const ACTIVE = "data-sb-active"
const FOCUS = "data-sb-focus"
const PSEUDO = /:hover\b|:active\b|:focus-visible\b/
// A negated :focus-visible (:not(:focus-visible)) would match every element without the attribute, so such a rule keeps its
// :focus-visible and only its :hover / :active (if any) are swapped.
const NEGATED_FOCUS = /:not\([^)]*:focus-visible/
const mirrored = new WeakSet<CSSStyleSheet>() const mirrored = new WeakSet<CSSStyleSheet>()
function mirror(list: CSSStyleSheet | CSSGroupingRule) { function mirror(list: CSSStyleSheet | CSSGroupingRule) {
for (let i = list.cssRules.length - 1; i >= 0; i--) { for (let i = list.cssRules.length - 1; i >= 0; i--) {
const rule = list.cssRules[i] const rule = list.cssRules[i]
if (rule instanceof CSSStyleRule) { if (rule instanceof CSSStyleRule) {
if (/:hover\b|:active\b/.test(rule.selectorText)) { if (PSEUDO.test(rule.selectorText)) {
list.insertRule( // `base` keeps :focus-visible (the pre-2026-11-13 clone), so real focus + data-sb-hover / data-sb-active still matches
rule.cssText.replace(/:hover\b/g, `[${HOVER}]`).replace(/:active\b/g, `[${ACTIVE}]`), // a compound rule such as :focus-visible:active (Slider Focus + Drag). `full` also swaps :focus-visible.
i + 1, const base = rule.cssText.replace(/:hover\b/g, `[${HOVER}]`).replace(/:active\b/g, `[${ACTIVE}]`)
) const full = NEGATED_FOCUS.test(rule.selectorText) ? base : base.replace(/:focus-visible\b/g, `[${FOCUS}]`)
if (full !== base) list.insertRule(full, i + 1)
if (base !== rule.cssText) list.insertRule(base, i + 1)
} }
} else if ("cssRules" in rule) { } else if ("cssRules" in rule) {
mirror(rule as CSSGroupingRule) mirror(rule as CSSGroupingRule)
+24 -18
View File
@@ -6,16 +6,16 @@ This is a classification record. No Story IDs, exports or titles are renamed. HI
## Summary ## Summary
- PRIMARY: 82 - PRIMARY: 73
- VERIFICATION: 256 - VERIFICATION: 266
- UTILITY: 49 - UTILITY: 49
- LEGACY / DUPLICATE: 0 - LEGACY / DUPLICATE: 0
- KEEP: 82 - KEEP: 73
- HIDE: 305 - HIDE: 315
- MERGE_CANDIDATE: 33 - MERGE_CANDIDATE: 33
- DELETE_CANDIDATE: 0 - DELETE_CANDIDATE: 0
- Story files: 89 - Story files: 90
- Stories: 387 - Stories: 388
- Hierarchy: Foundation → Components → Patterns → Chart → SW → 조직도 → 노선도 → Verification → Theme - Hierarchy: Foundation → Components → Patterns → Chart → SW → 조직도 → 노선도 → Verification → Theme
## Full inventory ## 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. - Intersection — 교차로; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- Junction — 분기점; 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. - 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. - 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. - 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: [] 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. - 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. - 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: [] 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. - 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. - 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. - 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: [] 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. - 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. - 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. - 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: [] 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. - 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. - 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. - 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: [] 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. - Open — Open; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
### Components/Search & Date/Date Picker ### 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: [] 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. - 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. - 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. - 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: [] 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. - Open — Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
### Components/Form/Input ### 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: [] 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. - 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. - 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. - 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. - 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. - 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 ### Components/Data & Display/Separator
Source: `src/components/separator.stories.tsx`; meta.tags: [] 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: [] 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. - 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. - 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. - 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: [] 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. - 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. - 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. - 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: [] 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. - 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. - 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. - 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": { "summary": {
"storyFiles": 89, "storyFiles": 90,
"stories": 387, "stories": 388,
"categories": { "categories": {
"PRIMARY": 82, "PRIMARY": 73,
"VERIFICATION": 256, "VERIFICATION": 266,
"UTILITY": 49, "UTILITY": 49,
"LEGACY / DUPLICATE": 0 "LEGACY / DUPLICATE": 0
}, },
"decisions": { "decisions": {
"KEEP": 82, "KEEP": 73,
"HIDE": 305, "HIDE": 315,
"MERGE_CANDIDATE": 33, "MERGE_CANDIDATE": 33,
"DELETE_CANDIDATE": 0 "DELETE_CANDIDATE": 0
}, },
@@ -90,7 +90,7 @@
{ {
"id": "center-graphic-facility--roadside", "id": "center-graphic-facility--roadside",
"exportName": "Roadside", "exportName": "Roadside",
"name": "휴게소 · 졸음쉼터", "name": "연계 노선",
"storyTags": [], "storyTags": [],
"effectiveTags": [], "effectiveTags": [],
"category": "PRIMARY", "category": "PRIMARY",
@@ -430,12 +430,16 @@
"id": "components-overlay-alert-dialog--all", "id": "components-overlay-alert-dialog--all",
"exportName": "All", "exportName": "All",
"name": "Alert Dialog", "name": "Alert Dialog",
"storyTags": [], "storyTags": [
"effectiveTags": [], "verification"
"category": "PRIMARY", ],
"decision": "KEEP", "effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null, "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", "id": "components-overlay-alert-dialog--confirmation-open",
@@ -901,12 +905,16 @@
"id": "components-search-date-calendar--all", "id": "components-search-date-calendar--all",
"exportName": "All", "exportName": "All",
"name": "Calendar", "name": "Calendar",
"storyTags": [], "storyTags": [
"effectiveTags": [], "verification"
"category": "PRIMARY", ],
"decision": "KEEP", "effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null, "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", "id": "components-search-date-calendar--single-selection",
@@ -1106,15 +1114,17 @@
"exportName": "All", "exportName": "All",
"name": "Checkbox", "name": "Checkbox",
"storyTags": [ "storyTags": [
"verification",
"play-fn" "play-fn"
], ],
"effectiveTags": [ "effectiveTags": [
"verification",
"play-fn" "play-fn"
], ],
"category": "PRIMARY", "category": "VERIFICATION",
"decision": "KEEP", "decision": "HIDE",
"candidate": null, "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", "id": "components-form-checkbox--unchecked",
@@ -1232,12 +1242,16 @@
"id": "components-disclosure-collapsible--all", "id": "components-disclosure-collapsible--all",
"exportName": "All", "exportName": "All",
"name": "Collapsible", "name": "Collapsible",
"storyTags": [], "storyTags": [
"effectiveTags": [], "verification"
"category": "PRIMARY", ],
"decision": "KEEP", "effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null, "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", "id": "components-disclosure-collapsible--closed",
@@ -1451,12 +1465,16 @@
"id": "components-overlay-context-menu--all", "id": "components-overlay-context-menu--all",
"exportName": "All", "exportName": "All",
"name": "Context Menu", "name": "Context Menu",
"storyTags": [], "storyTags": [
"effectiveTags": [], "verification"
"category": "PRIMARY", ],
"decision": "KEEP", "effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null, "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", "id": "components-overlay-context-menu--open",
@@ -1657,12 +1675,16 @@
"id": "components-overlay-drawer--all", "id": "components-overlay-drawer--all",
"exportName": "All", "exportName": "All",
"name": "Drawer", "name": "Drawer",
"storyTags": [], "storyTags": [
"effectiveTags": [], "verification"
"category": "PRIMARY", ],
"decision": "KEEP", "effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null, "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", "id": "components-overlay-drawer--open",
@@ -1753,12 +1775,16 @@
"id": "components-overlay-hover-card--all", "id": "components-overlay-hover-card--all",
"exportName": "All", "exportName": "All",
"name": "Hover Card", "name": "Hover Card",
"storyTags": [], "storyTags": [
"effectiveTags": [], "verification"
"category": "PRIMARY", ],
"decision": "KEEP", "effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null, "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", "id": "components-overlay-hover-card--open",
@@ -2346,15 +2372,17 @@
"exportName": "All", "exportName": "All",
"name": "Radio Group", "name": "Radio Group",
"storyTags": [ "storyTags": [
"verification",
"play-fn" "play-fn"
], ],
"effectiveTags": [ "effectiveTags": [
"verification",
"play-fn" "play-fn"
], ],
"category": "PRIMARY", "category": "VERIFICATION",
"decision": "KEEP", "decision": "HIDE",
"candidate": null, "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", "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", "file": "src/components/separator.stories.tsx",
"title": "Components/Data & Display/Separator", "title": "Components/Data & Display/Separator",
@@ -3114,15 +3164,17 @@
"exportName": "All", "exportName": "All",
"name": "Switch", "name": "Switch",
"storyTags": [ "storyTags": [
"verification",
"play-fn" "play-fn"
], ],
"effectiveTags": [ "effectiveTags": [
"verification",
"play-fn" "play-fn"
], ],
"category": "PRIMARY", "category": "VERIFICATION",
"decision": "KEEP", "decision": "HIDE",
"candidate": null, "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", "id": "components-form-switch--off",
@@ -3736,12 +3788,16 @@
"id": "components-form-toggle--all", "id": "components-form-toggle--all",
"exportName": "All", "exportName": "All",
"name": "Toggle", "name": "Toggle",
"storyTags": [], "storyTags": [
"effectiveTags": [], "verification"
"category": "PRIMARY", ],
"decision": "KEEP", "effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null, "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", "id": "components-form-toggle--states",
@@ -5244,7 +5300,7 @@
{ {
"id": "sw-footer--full-footer", "id": "sw-footer--full-footer",
"exportName": "FullFooter", "exportName": "FullFooter",
"name": "SW 하단 명령어바", "name": "SW 명령어바",
"storyTags": [], "storyTags": [],
"effectiveTags": [], "effectiveTags": [],
"category": "PRIMARY", "category": "PRIMARY",
+274
View File
@@ -1,5 +1,279 @@
# Working Context # 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`) ## 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) 불변. - 범위: `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과 일치). - 시설물 총 너비(기준 이름): 터널 위치도 210 · 실사화 220 (Source reference total과 일치).
- 노선: 중심선 18u, 윤곽 중심선의 20%, 그림자 층 순서 Shadow → Outline → Primary Stroke(`CONFIRMED`). - 노선: 중심선 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`이며 이 패키지는 폰트를 포함하지 않는다. - 글꼴: Noto Sans KR(Canonical). 폰트 파일 배포와 fallback 순서는 `PLATFORM_MAPPING`/`TBD`이며 이 패키지는 폰트를 포함하지 않는다.
- 상태: 노선만 상태(기본/공사중/계획)를 가진다. Facility는 정적이며 Selected/Hover가 없다. - 상태: 노선만 상태(기본/공사중/계획)를 가진다. 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`으로 지정한다. `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. 미확정 / 보류 (원본 기준) ## 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`에 상태 표가 있다. 이 구현은 위 항목을 확정하지 않는다. 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`에 상태 표가 있다. 이 구현은 위 항목을 확정하지 않는다.
+1 -1
View File
@@ -11,7 +11,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Accordion (Radix `Accordion`) | | 기반 | shadcn/ui Accordion (Radix `Accordion`) |
| 대표 Story | `Components/Disclosure/Accordion` → `All` (이름 "Accordion") | | 대표 Story | `Components/Disclosure/Accordion` → `All` (이름 "Accordion", 사이드바 `접기·펼치기`, Collapsible 예제 포함) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
+3 -2
View File
@@ -11,7 +11,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Alert Dialog (Radix `AlertDialog`) | | 기반 | shadcn/ui Alert Dialog (Radix `AlertDialog`) |
| 대표 Story | `Components/Overlay/Alert Dialog` → `All` (이름 "Alert Dialog") | | 대표 Story | `Components/Overlay/Dialog` → `All`의 "Alert Dialog" 섹션(사이드바 `대화상자`). 기존 `Components/Overlay/Alert Dialog` → `All`(`components-overlay-alert-dialog--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -75,7 +75,8 @@ Radix AlertDialog 구조 전부: Portal·Overlay·modal·focus trap·초기 focu
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Alert Dialog"): 트리거 2개(로그아웃 = 일반 확인, 삭제 = destructive)가 각각 실제 Alert Dialog를 연다. - 사이드바 통합(2026-11-12): 사이드바에는 `대화상자`(Dialog 대표 Story) 하나만 보이고, 그 화면의 "Alert Dialog" 섹션에서 트리거 2개(로그아웃 = 일반 확인, 삭제 = destructive)가 각각 실제 Alert Dialog를 연다. 예제는 공용 파일 `src/components/alert-dialog-examples.tsx`(`AlertDialogConfirmation`, `AlertDialogDestructive`)에 있고 두 Story 파일이 함께 쓴다.
- 이 파일의 `All`("Alert Dialog", 같은 트리거 2개)은 `verification` 태그로 사이드바에서만 숨기며 ID·URL은 그대로다.
- verification: `Confirmation`, `Destructive`(처음부터 열림). - verification: `Confirmation`, `Destructive`(처음부터 열림).
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1 -1
View File
@@ -69,7 +69,7 @@ Component Mapping 위치: `packages/ui/src/theme/breadcrumb.css` (`--hm-breadcru
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Breadcrumb"): Variants(Basic(3 depth), Ellipsis(Dropdown Menu 조합), Custom link(asChild)와 custom separator)를 PC 균등 3열로, Usage(Long path, 최대 900px)를 한 페이지에 둔다(2026-11-09 Overview 제거). - 대표 Story `All`("Breadcrumb"): Variants(Basic(3 depth), Ellipsis(Dropdown Menu 조합), Custom(asChild link와 custom separator))를 iframe 1000px 이상 균등 3열·미만 1열로, Usage(Long path, 섹션 전체 너비, 좁으면 자연 줄바꿈)를 한 페이지에 둔다(2026-11-09 Overview 제거, 2026-11-13 Custom 명칭·1/3열·900px 제한 제거).
- verification: `Basic`, `Ellipsis`, `Long Path`. - verification: `Basic`, `Ellipsis`, `Long Path`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+3 -2
View File
@@ -11,7 +11,7 @@
| 구현 상태 | HM 스타일 적용 완료 (`hm-*` class 없음) | | 구현 상태 | HM 스타일 적용 완료 (`hm-*` class 없음) |
| 기준일 | 2026-10-08 (Role × Appearance 계약·현재 Story 대조) | | 기준일 | 2026-10-08 (Role × Appearance 계약·현재 Story 대조) |
| 기반 | shadcn/ui Button (CVA, Radix `Slot`) | | 기반 | shadcn/ui Button (CVA, Radix `Slot`) |
| 대표 Story | `Components/Form/Button` → `All` (이름 "Button") | | 대표 Story | `Components/Form/Button` → `All` (이름 "Button", 사이드바 `버튼`, Toggle Button 예제 포함) |
| 보류 | Brand label Text Border 두께(EXPERIMENTAL, 미적용), Focus(ui_viz TBD) | | 보류 | Brand label Text Border 두께(EXPERIMENTAL, 미적용), Focus(ui_viz TBD) |
## 2. 적용 근거 ## 2. 적용 근거
@@ -150,7 +150,8 @@ CVA 구조, `data-slot`, `asChild`(Radix `Slot`), `className` 확장, focus-visi
## 11. Storybook ## 11. Storybook
- 대표 Story `All`("Button")이 기본 sidebar의 유일한 Button 항목이다 → 1뎁스. - 대표 Story `All`("Button")이 기본 sidebar의 유일한 Button 항목이다 → 1뎁스.
- `All`의 구역: Sizes → Variants → States(2026-11-09 Overview 제거; 단독 Overview 버튼은 Sizes의 Brand Medium과 같아 통합). 기본 버튼·크기·허용 조합·Brand/Standard 상태를 보여준다. Red Solid와 Outline은 Variants와 Standard States에도 포함되며, 삭제·취소 세트와 Long Label은 verification Story에서 따로 확인한다. 2026-11-12 비교형 레이아웃: 상단은 Sizes | Variants 두 섹션이 좌우 반반(미리보기 1080px 이상, 미만은 세로)이며, 각 섹션은 그룹당 한 행(왼쪽 고정 폭 그룹명 + 가로 버튼 행, 640px 이하에서는 그룹명이 위)이다. 하단 States는 Brand | Standard Solid | Standard Outline 3열(미리보기 1250px 이상, 890px 이상은 2열, 미만은 1열)이고, 각 열은 행=상태(Brand·Standard 모두 Default/Hover/Pressed/Disabled 4개), 열=Variants와 같은 순서의 컬러인 매트릭스다. 실제 포인터 상태를 확인하는 Interact 버튼은 verification Story "Brand States"에만 있다. 열은 매트릭스 폭 비율로 전체 폭을 나눠 쓰고 매트릭스는 열 시작에 놓인다. 버튼 사이 간격은 Sizes·Variants·States 모두 가로·세로 8px로 같다. 1열에서 표가 화면보다 넓으면 표 영역 안에서만 가로 스크롤한다. 레이아웃만 바꿨고 버튼 스타일·상태 표현은 그대로다. - 2026-11-13 현재 `All`의 구역: Brand | Standard Solid | Standard Outline 세 섹션(H2, 공통 제목 → 콘텐츠 12px). 각 섹션 안에 Sizes(H3 → 8px → Small / Medium / Large)와 States(H3 → 8px → 행 Default/Hover/Pressed/Disabled × 해당 Treatment 컬러 매트릭스)가 24px 간격으로 온다. 페이지 단위 Sizes·Variants·States 제목은 없다(컬러 조합은 매트릭스 열과 verification `Roles`에서 확인). 미리보기 1400px 이상에서 같은 너비 3열, 미만은 Brand → Solid → Outline 1열(섹션 간격 28px)이다. 2026-11-13: 매트릭스가 자기 열보다 넓으면(390px에서 420px Standard Solid) 세 Treatment가 함께 상태별 세로 배치(상태 이름 12/16/400 보조색 → 4px → 버튼 줄, 버튼 간격 8px·줄바꿈, 상태 간 12px)로 바뀌어 표 내부·페이지 가로 스크롤이 없다. 버튼 크기·상태·색상과 props는 그대로다. 표 하단에는 독립 H2 "Toggle Button" 한 섹션(./toggle-examples, 기존 Toggle 대표 화면과 같은 예시)이 버튼 열과 36px 간격으로 오며, 그룹 H3 Variants / States(Off / On, Disabled) · Groups(단독, 단일 선택, 다중 선택 — 연결 그룹 예시, 이 페이지에서는 "연결 그룹 · " 접두어 생략) · Sizes(Small, Medium, Large — 2026-11-13 Default 라벨을 Button과 같은 Medium으로, 글자 토글은 prop 값 대신 예시 문구 "알림") 3개를 버튼과 같이 미리보기 1400px 이상에서 같은 너비 3열(Brand / Standard Solid / Standard Outline 열과 같은 위치), 미만은 1열(그룹 간 24px)로 놓는다(2026-11-13). 그룹 라벨은 버튼과 같은 규칙(들여쓰기 0, H3 → 8px)이고, 각 행은 States 매트릭스와 같은 형식(왼쪽 라벨 14/20/500 보조색 → 8px → 컨트롤, 행 간 8px, 그룹 안 컨트롤 시작선 정렬)이다. 버튼 매트릭스가 세로 배치로 바뀌면 Toggle 행도 함께 세로 배치(라벨 12/16/400 → 4px → 컨트롤, 행 간 12px)로 바뀐다. 버튼 스타일·상태 표현·매트릭스 구조는 그대로다. 아래 2026-11-12 설명은 이전 배치 기록이다.
- `All`의 구역(2026-11-12 기록): Sizes → Variants → States(2026-11-09 Overview 제거; 단독 Overview 버튼은 Sizes의 Brand Medium과 같아 통합). 기본 버튼·크기·허용 조합·Brand/Standard 상태를 보여준다. Red Solid와 Outline은 Variants와 Standard States에도 포함되며, 삭제·취소 세트와 Long Label은 verification Story에서 따로 확인한다. 2026-11-12 비교형 레이아웃: 상단은 Sizes | Variants 두 섹션이 좌우 반반(미리보기 1080px 이상, 미만은 세로)이며, 각 섹션은 그룹당 한 행(왼쪽 고정 폭 그룹명 + 가로 버튼 행, 640px 이하에서는 그룹명이 위)이다. 하단 States는 Brand | Standard Solid | Standard Outline 3열(미리보기 1250px 이상, 890px 이상은 2열, 미만은 1열)이고, 각 열은 행=상태(Brand·Standard 모두 Default/Hover/Pressed/Disabled 4개), 열=Variants와 같은 순서의 컬러인 매트릭스다. 실제 포인터 상태를 확인하는 Interact 버튼은 verification Story "Brand States"에만 있다. 열은 매트릭스 폭 비율로 전체 폭을 나눠 쓰고 매트릭스는 열 시작에 놓인다. 버튼 사이 간격은 Sizes·Variants·States 모두 가로·세로 8px로 같다. 1열에서 표가 화면보다 넓으면 표 영역 안에서만 가로 스크롤한다. 레이아웃만 바꿨고 버튼 스타일·상태 표현은 그대로다.
- verification(`tags: ["verification"]`, 기본 sidebar 제외): `Roles`, `Sizes`, `States`("Brand States"), `StandardStates`, `LongLabel`, `KoreanLabels`, `IconTextNarrow`, `Destructive`, `Playground`(HM 전용), `Compatibility`(ghost/link, default/outline 별칭). Controls는 조합별 선택을 제한하지 않으므로 §6.1의 허용 조합을 직접 지킨다. Verification은 기술 점검용이며 운영 화면의 사용 위치·세트 규칙은 §5가 우선한다. - verification(`tags: ["verification"]`, 기본 sidebar 제외): `Roles`, `Sizes`, `States`("Brand States"), `StandardStates`, `LongLabel`, `KoreanLabels`, `IconTextNarrow`, `Destructive`, `Playground`(HM 전용), `Compatibility`(ghost/link, default/outline 별칭). Controls는 조합별 선택을 제한하지 않으므로 §6.1의 허용 조합을 직접 지킨다. Verification은 기술 점검용이며 운영 화면의 사용 위치·세트 규칙은 §5가 우선한다.
- 대표 경로/ID: `Components/Form/Button → All` (`components-form-button--all`), `Components/Overlay/Dialog → All` (`components-overlay-dialog--all`), `Components/Overlay/Alert Dialog → All` (`components-overlay-alert-dialog--all`), `Patterns/Data/Filter Bar → Filter Bar` (`patterns-data-filter-bar--all`). 삭제 세트 점검 ID는 `components-form-button--destructive`(verification)다. - 대표 경로/ID: `Components/Form/Button → All` (`components-form-button--all`), `Components/Overlay/Dialog → All` (`components-overlay-dialog--all`), `Components/Overlay/Alert Dialog → All` (`components-overlay-alert-dialog--all`), `Patterns/Data/Filter Bar → Filter Bar` (`patterns-data-filter-bar--all`). 삭제 세트 점검 ID는 `components-form-button--destructive`(verification)다.
- 운영 규칙은 `프로젝트-지침.md` §6.3, §7 참조. - 운영 규칙은 `프로젝트-지침.md` §6.3, §7 참조.
+1 -1
View File
@@ -10,7 +10,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Calendar = **react-day-picker v10** | | 기반 | shadcn/ui Calendar = **react-day-picker v10** |
| 대표 Story | `Components/Search & Date/Calendar` → `All` (이름 "Calendar") | | 대표 Story | `Components/Search & Date/Date Picker` → `All`의 "Calendar · …" 섹션(사이드바 `날짜 선택`). 기존 `Components/Search & Date/Calendar` → `All`(`components-search-date-calendar--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
+2 -2
View File
@@ -11,7 +11,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Checkbox (Radix `Checkbox`) | | 기반 | shadcn/ui Checkbox (Radix `Checkbox`) |
| 대표 Story | `Components/Form/Checkbox` → `All` (이름 "Checkbox") | | 대표 Story | `Components/Form/Selection Controls` → `All`의 상태 매트릭스 Checkbox 열(Default·Hover·Focus·Disabled·Validation)과 Variants의 Indeterminate(사이드바 `선택 컨트롤`, `components-form-selection-controls--all`). 기존 `Components/Form/Checkbox` → `All`(`components-form-checkbox--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -100,7 +100,7 @@ Radix 구조·ARIA(`role=checkbox`, `aria-checked`)·키보드, `data-slot`, `cn
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Checkbox"): States `unchecked`, `checked`, `indeterminate`, `hover`, `focus`, `disabled`(PC 3열 × 2행)와 Validation `error`. Overview·Usage 구역은 2026-11-09에 없앴고 `long`(Long Label) 예시는 숨김 verification Story `LongLabel`로 옮겼다. 옵션 Label은 Story에서만 `--hm-label-text`를 `--foreground`로 덮어 기본 텍스트 색으로 보이며(공용 Label은 그대로, 비활성 Checkbox 옆 Label은 `peer-disabled`로 보조색 유지), 예시 제목은 보조색이다. Focus는 `play`로 실제 focus. `indeterminate`는 controlled 예시를 겸해 클릭하면 checked가 된다(Radix, 2026-10-22 별도 `interactive` 구역 통합). `hover`는 Unchecked·Checked를 Storybook 전용 `data-sb-hover`(`.storybook/pseudo-states.ts`)로 고정해 보여 준다. Checkbox API가 아니며 소비 코드에 붙이지 않는다. - 숨김 기존 `All`("Checkbox", URL 직접 접근용; 사이드바 대표는 Selection Controls, §표 참고): States `unchecked`, `checked`, `indeterminate`, `hover`, `focus`, `disabled`(PC 3열 × 2행)와 Validation `error`. Overview·Usage 구역은 2026-11-09에 없앴고 `long`(Long Label) 예시는 숨김 verification Story `LongLabel`로 옮겼다. 옵션 Label은 Story에서만 `--hm-label-text`를 `--foreground`로 덮어 기본 텍스트 색으로 보이며(공용 Label은 그대로, 비활성 Checkbox 옆 Label은 `peer-disabled`로 보조색 유지), 예시 제목은 보조색이다. Focus는 `play`로 실제 focus. `indeterminate`는 controlled 예시를 겸해 클릭하면 checked가 된다(Radix, 2026-10-22 별도 `interactive` 구역 통합). `hover`는 Unchecked·Checked를 Storybook 전용 `data-sb-hover`(`.storybook/pseudo-states.ts`)로 고정해 보여 준다. Checkbox API가 아니며 소비 코드에 붙이지 않는다.
- verification: `Unchecked`, `Checked`, `Indeterminate`, `Disabled`, `Error`, `LongLabel`("Long Label"), `Playground`. - verification: `Unchecked`, `Checked`, `Indeterminate`, `Disabled`, `Error`, `LongLabel`("Long Label"), `Playground`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1 -1
View File
@@ -9,7 +9,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Collapsible (Radix `Collapsible`) | | 기반 | shadcn/ui Collapsible (Radix `Collapsible`) |
| 대표 Story | `Components/Disclosure/Collapsible` → `All` (이름 "Collapsible") | | 대표 Story | `Components/Disclosure/Accordion` → `All`의 "Collapsible · …" 섹션(사이드바 `접기·펼치기`). 기존 `Components/Disclosure/Collapsible` → `All`(`components-disclosure-collapsible--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
+2 -2
View File
@@ -102,8 +102,8 @@ Popover: Portal, collision, Escape, 바깥 클릭 닫힘, trigger focus 복귀.
## 10. Storybook ## 10. Storybook
- 대표 Story `All`("Combobox"): Overview(Placeholder + search, Remix는 disabled option), Sizes(Medium, 같은 size의 Select / Combobox 비교), States(Selected(controlled), Disabled), Usage(Long option). Label은 Story 조합이다. 대표 화면은 선택값 표시(`value = …`)를 숨기며 숨김 `Controlled`는 유지한다. - 대표 Story `All`("Combobox"): Sizes(Large·Medium), States(Selected(controlled), Disabled), Usage(Long option, 같은 size의 Select / Combobox 비교). Overview 구역은 없다. Label은 Story 조합이다. 대표 화면은 선택값 표시(`value = …`)를 숨기며 숨김 `Controlled`는 유지한다.
- 2026-11-12 폼 요소 전시 통일(Input·Select 기준): Sizes(`Default`, `Medium`)·States(`Selected`, `Disabled`)는 상태명을 콤보박스 Label 자리에 한 번만 표시하고 반복되던 H3 상태명·필드 Label(`프레임워크`, `선택됨 (controlled)`, `비활성`)을 없앴다. 접근성 이름은 Trigger의 `aria-label="프레임워크 (상태명)"`(보이는 상태명 포함, 팝업·목록 이름에도 쓰임). 두 섹션은 Input과 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열, 공통 들여쓰기)를 쓴다. Usage(Long option, Select / Combobox 비교)와 verification Story, Combobox·토큰·API는 변경 없다. - 2026-11-12 폼 요소 전시 통일(Input·Select 기준): Sizes(`Default`, `Medium`; 2026-11-13 기본 크기가 실제 large라 `Default` 라벨을 `Large`로 정정)·States(`Selected`, `Disabled`)는 상태명을 콤보박스 Label 자리에 한 번만 표시하고 반복되던 H3 상태명·필드 Label(`프레임워크`, `선택됨 (controlled)`, `비활성`)을 없앴다. 접근성 이름은 Trigger의 `aria-label="프레임워크 (상태명)"`(보이는 상태명 포함, 팝업·목록 이름에도 쓰임). 두 섹션은 Input과 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열, 공통 들여쓰기)를 쓴다. Usage(Long option, Select / Combobox 비교)와 verification Story, Combobox·토큰·API는 변경 없다.
- verification: `Empty`, `Controlled`, `Long Option`. - verification: `Empty`, `Controlled`, `Long Option`.
## 11. 사용자 확정 결정 / 보류 ## 11. 사용자 확정 결정 / 보류
+3 -2
View File
@@ -9,7 +9,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-03 | | 기준일 | 2026-10-03 |
| 기반 | shadcn/ui Context Menu (Radix `ContextMenu`, `radix-ui` 1.6.7 / `@radix-ui/react-context-menu` 2.3.7) | | 기반 | shadcn/ui Context Menu (Radix `ContextMenu`, `radix-ui` 1.6.7 / `@radix-ui/react-context-menu` 2.3.7) |
| 대표 Story | `Components/Overlay/Context Menu` → `All` (이름 "Context Menu") | | 대표 Story | `Components/Overlay/Dropdown Menu` → `All`의 "Context Menu" 섹션(사이드바 `팝업 메뉴`). 기존 `Components/Overlay/Context Menu` → `All`(`components-overlay-context-menu--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -61,7 +61,8 @@ Dropdown Menu와 동일: shadcn 시각 class(`rounded`, `shadow`, `p-1`, `focus:
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Context Menu"): 점선 영역을 우클릭하면 전체 메뉴가 열린다(Label, Item + Shortcut, disabled, Separator, Checkbox, Radio Group, Sub menu, destructive). - 사이드바 통합(2026-11-12): 사이드바에는 `팝업 메뉴`(Dropdown Menu 대표 Story) 하나만 보이고, 그 화면의 "Context Menu" 섹션에서 점선 영역을 우클릭하면 전체 메뉴가 열린다(Label, Item + Shortcut, disabled, Separator, Checkbox, Radio Group, Sub menu, destructive). 예제는 공용 파일 `src/components/context-menu-examples.tsx`(`ContextMenuFull`)에 있고 두 Story 파일이 함께 쓴다.
- 이 파일의 `All`("Context Menu", 같은 점선 영역)은 `verification` 태그로 사이드바에서만 숨기며 ID·URL은 그대로다.
- verification: `Open`(로드 시 합성 `contextmenu` 이벤트로 열린 상태, fullscreen). - verification: `Open`(로드 시 합성 `contextmenu` 이벤트로 열린 상태, fullscreen).
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+2 -2
View File
@@ -12,7 +12,7 @@
| 구현 상태 | 구현 완료 (single-date, 현재 HM 규격) | | 구현 상태 | 구현 완료 (single-date, 현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | `Popover`(public API) + `Calendar`(public API) 조합. shadcn 공식 Date Picker는 예시 조합이다 | | 기반 | `Popover`(public API) + `Calendar`(public API) 조합. shadcn 공식 Date Picker는 예시 조합이다 |
| 대표 Story | `Components/Search & Date/Date Picker` → `All` (이름 "Date Picker") | | 대표 Story | `Components/Search & Date/Date Picker` → `All` (이름 "Date Picker", 사이드바 `날짜 선택`, Calendar 예제 포함) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -91,7 +91,7 @@ Popover: Portal, collision 처리, Escape, 바깥 클릭 닫힘, trigger focus
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Date Picker"): Empty, Selected(`2026.10.06`), 기본 형식(`2026.01.09`)/custom override, Controlled(대표 화면은 값 표시 숨김, 숨김 `Controlled`는 표시), Medium, Disabled dates(5일 이전·일요일), Disabled를 한 페이지에 둔다. Label은 Story 조합이며 `today`는 Calendar prop으로 2026-10-02에 고정. - 대표 Story `All`("Date Picker"): Empty, Selected(`2026.10.06`), 기본 형식(`2026.01.09`)/custom override, Controlled(대표 화면은 값 표시 숨김, 숨김 `Controlled`는 표시), Medium, Disabled dates(5일 이전·일요일), Disabled를 한 페이지에 둔다. Label은 Story 조합이며 `today`는 Calendar prop으로 2026-10-02에 고정.
- 2026-11-12 폼 요소 전시 통일(Input·Select 기준): Sizes(`Default`, `Medium`)·States(`Selected`, `Controlled`, `Disabled dates`, `Disabled`)는 상태명을 날짜 선택 Label 자리에 한 번만 표시하고 반복되던 H3 상태명·필드 Label을 없앴다. 접근성 이름은 Trigger의 `aria-label="시작일|마감일|날짜 (상태명)"`(보이는 상태명 포함). `Disabled dates`의 규칙 문구 "5일 이전과 일요일은 선택 불가"는 Trigger 아래 도움말(12/18/400 secondary)로 옮기고 `aria-describedby`로 연결했다. 두 섹션은 Input과 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열, 공통 들여쓰기)를 쓴다. Usage(형식 2종)와 verification Story, DatePicker·토큰·API는 변경 없다. Storybook 사이드바 표시명은 `날짜 선택`(Story ID 변경 없음). - 2026-11-12 폼 요소 전시 통일(Input·Select 기준): Sizes(`Default`, `Medium`; 2026-11-13 기본 크기가 실제 large라 `Default` 라벨을 `Large`로 정정, Usage Default format의 Label에서 H3와 반복되던 "기본 형식 · " 제거)·States(`Selected`, `Controlled`, `Disabled dates`, `Disabled`)는 상태명을 날짜 선택 Label 자리에 한 번만 표시하고 반복되던 H3 상태명·필드 Label을 없앴다. 접근성 이름은 Trigger의 `aria-label="시작일|마감일|날짜 (상태명)"`(보이는 상태명 포함). `Disabled dates`의 규칙 문구 "5일 이전과 일요일은 선택 불가"는 Trigger 아래 도움말(12/18/400 secondary)로 옮기고 `aria-describedby`로 연결했다. 두 섹션은 Input과 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열, 공통 들여쓰기)를 쓴다. Usage(형식 2종)와 verification Story, DatePicker·토큰·API는 변경 없다. Storybook 사이드바 표시명은 `날짜 선택`(Story ID 변경 없음).
- verification: `Empty`, `Selected`, `Controlled`. - verification: `Empty`, `Selected`, `Controlled`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+3 -2
View File
@@ -13,7 +13,7 @@
| 구현 상태 | HM 스타일 적용 완료 (`<DialogContent />`만으로 적용, `hm-dialog` class 없음) | | 구현 상태 | HM 스타일 적용 완료 (`<DialogContent />`만으로 적용, `hm-dialog` class 없음) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Dialog + Radix `Dialog` | | 기반 | shadcn/ui Dialog + Radix `Dialog` |
| 대표 Story | `Components/Overlay/Dialog` → `All` (이름 "Dialog") | | 대표 Story | `Components/Overlay/Dialog` → `All` (이름 "Dialog", 사이드바 `대화상자`, Alert Dialog 예제 포함) |
| 보류 | Overlay 수치, Close 버튼 규격, 초기 focus 대상, 좁은 화면 Actions 배치 등 (§11) | | 보류 | Overlay 수치, Close 버튼 규격, 초기 focus 대상, 좁은 화면 Actions 배치 등 (§11) |
## 2. 적용 근거 ## 2. 적용 근거
@@ -111,7 +111,8 @@ Radix 구조 전부를 그대로 둔다: Portal(`DialogPortal`), Overlay, modal
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Dialog")이 기본 sidebar의 유일한 Dialog 항목이다 → 1뎁스. overlay라 StateBoard를 쓰지 않고, 트리거 버튼 6개(기본, 긴 내용, 넓은 표, 본문 스크롤, 삭제 확인, 폼)가 각각 실제 Dialog를 연다. - 대표 Story `All`("Dialog")이 기본 sidebar의 유일한 Dialog 항목이다 → 1뎁스. 트리거마다 단일 열 StateBoard(SpecimenGrid)에 두며, Variants(기본, 긴 내용, 넓은 표, 본문 스크롤)·Usage(삭제 확인, 폼)·Alert Dialog(확인, 삭제) 3구역의 트리거가 각각 실제 대화상자를 연다.
- 사이드바 통합(2026-11-12): 같은 대표 Story에 "Alert Dialog" 섹션(로그아웃 = 일반 확인, 삭제 = destructive)을 두어 사이드바 `대화상자` 하나에서 일반·확인·경고 대화상자를 모두 연다. 예제는 공용 파일 `src/components/alert-dialog-examples.tsx`를 Alert Dialog Story와 함께 쓴다(중복 구현 없음). Alert Dialog Story는 ID·URL을 유지하고 사이드바에서만 숨긴다([alert-dialog.md §9](alert-dialog.md#9-storybook)).
- 본문 스크롤은 ui_viz "세로 스크롤 · 본문만"과 같은 조건(앱 공통 Header 70px / Footer 48px 제외)이다. Portal 때문에 앱 높이는 trigger wrapper가 아니라 `DialogContent`의 `style`로 `--hm-dialog-app-header-height`/`--hm-dialog-app-footer-height`를 넘긴다. - 본문 스크롤은 ui_viz "세로 스크롤 · 본문만"과 같은 조건(앱 공통 Header 70px / Footer 48px 제외)이다. Portal 때문에 앱 높이는 trigger wrapper가 아니라 `DialogContent`의 `style`로 `--hm-dialog-app-header-height`/`--hm-dialog-app-footer-height`를 넘긴다.
- verification(`tags: ["verification"]`, 닫힌 채가 아니라 처음부터 열림): `Open`, `Long Content`, `Wide Content`, `Body Scroll`, `Delete Confirmation`. - verification(`tags: ["verification"]`, 닫힌 채가 아니라 처음부터 열림): `Open`, `Long Content`, `Wide Content`, `Body Scroll`, `Delete Confirmation`.
- pseudo-state helper로 열린 상태를 흉내 내지 않는다. - pseudo-state helper로 열린 상태를 흉내 내지 않는다.
+1 -1
View File
@@ -11,7 +11,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Drawer = **vaul** (`vaul@1.1.2`, 이번에 `@hm-design/ui` dependency로 추가) + Radix Dialog | | 기반 | shadcn/ui Drawer = **vaul** (`vaul@1.1.2`, 이번에 `@hm-design/ui` dependency로 추가) + Radix Dialog |
| 대표 Story | `Components/Overlay/Drawer` → `All` (이름 "Drawer") | | 대표 Story | `Components/Overlay/Sheet` → `All`의 "Drawer" 섹션(사이드바 `시트·드로어`). 기존 `Components/Overlay/Drawer` → `All`(`components-overlay-drawer--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
+2 -1
View File
@@ -9,7 +9,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Dropdown Menu (Radix `DropdownMenu`) | | 기반 | shadcn/ui Dropdown Menu (Radix `DropdownMenu`) |
| 대표 Story | `Components/Overlay/Dropdown Menu` → `All` (이름 "Dropdown Menu") | | 대표 Story | `Components/Overlay/Dropdown Menu` → `All` (이름 "Dropdown Menu", 사이드바 `팝업 메뉴`, Context Menu 예제 포함) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -75,6 +75,7 @@ Radix 구조·ARIA(`role=menu`, `menuitem*`)·키보드(방향키, Home/End, Ent
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Dropdown Menu"): 트리거로 실제 메뉴를 연다(Label, Item + Shortcut, disabled, Separator, Checkbox, Radio Group, Sub menu, destructive). - 대표 Story `All`("Dropdown Menu"): 트리거로 실제 메뉴를 연다(Label, Item + Shortcut, disabled, Separator, Checkbox, Radio Group, Sub menu, destructive).
- 사이드바 통합(2026-11-12): 사이드바 표시명은 `팝업 메뉴`이고, 접근성용 숨김 H1 "Dropdown Menu · Context Menu"(화면에는 보이지 않음) 아래 "Dropdown Menu"와 "Context Menu" 섹션을 함께 둔다. Context Menu 예제는 공용 파일 `src/components/context-menu-examples.tsx`를 Context Menu Story와 함께 쓴다(중복 구현 없음). Context Menu Story는 ID·URL을 유지하고 사이드바에서만 숨긴다([context-menu.md §9](context-menu.md#9-storybook)).
- verification: `Open`(열린 상태, fullscreen). - verification: `Open`(열린 상태, fullscreen).
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1 -1
View File
@@ -9,7 +9,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Hover Card (Radix `HoverCard`) | | 기반 | shadcn/ui Hover Card (Radix `HoverCard`) |
| 대표 Story | `Components/Overlay/Hover Card` → `All` (이름 "Hover Card") | | 대표 Story | `Components/Overlay/Tooltip` → `All`의 "Hover Card" 섹션(사이드바 `툴팁·호버 카드`). 기존 `Components/Overlay/Hover Card` → `All`(`components-overlay-hover-card--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
+1
View File
@@ -86,6 +86,7 @@ native `<input>` 동작 전체(`type`, `file:` 스타일 class, `placeholder`),
- 대표 Story `All`("Input"): Sizes(Small / Medium / Large 비교) → States(`focus`, `disabled`, `readonly`) → Validation(`error`, `error-message`). Focus는 `play` 함수로 실제 focus를 준다. 2026-10-22 Overview(`default`, size 생략 = Large)는 Sizes의 Large와 같아 대표 화면에서 뺐고, 크기별 수치 목록·설명은 숨김 `Sizes`("Compare Sizes")에 남겼다. size 생략 시 Large(§5)는 그대로다. - 대표 Story `All`("Input"): Sizes(Small / Medium / Large 비교) → States(`focus`, `disabled`, `readonly`) → Validation(`error`, `error-message`). Focus는 `play` 함수로 실제 focus를 준다. 2026-10-22 Overview(`default`, size 생략 = Large)는 Sizes의 Large와 같아 대표 화면에서 뺐고, 크기별 수치 목록·설명은 숨김 `Sizes`("Compare Sizes")에 남겼다. size 생략 시 Large(§5)는 그대로다.
- 2026-11-12 대표 화면 정리: States·Validation도 Sizes처럼 상태명(Focus / Disabled / Readonly / Error / Error Message)을 입력창 Label 자리에 한 번만 표시하고, 반복되던 `Project name` Label과 별도 그룹 제목은 없앴다. 입력 필드의 접근성 이름은 `aria-label="Project name (상태명)"`으로 유지한다(보이는 상태명 포함). 세 섹션은 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열)를 쓰며 Validation 2개는 States 앞 두 열과 정렬된다. Story 전시만 바뀌었고 Input 컴포넌트·토큰·API·verification Story는 변경 없다. - 2026-11-12 대표 화면 정리: States·Validation도 Sizes처럼 상태명(Focus / Disabled / Readonly / Error / Error Message)을 입력창 Label 자리에 한 번만 표시하고, 반복되던 `Project name` Label과 별도 그룹 제목은 없앴다. 입력 필드의 접근성 이름은 `aria-label="Project name (상태명)"`으로 유지한다(보이는 상태명 포함). 세 섹션은 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열)를 쓰며 Validation 2개는 States 앞 두 열과 정렬된다. Story 전시만 바뀌었고 Input 컴포넌트·토큰·API·verification Story는 변경 없다.
- 2026-11-13: States의 Disabled를 Focus·Validation과 같은 Medium(34px)으로 맞췄다. Readonly는 컴포넌트 규칙(텍스트만, size와 무관하게 Large 수치)이라 42px 그대로다. Input 기본값·크기 정책은 변경 없음.
- verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Disabled`, `Readonly`, `Sizes`("Compare Sizes"), `Small`, `Medium`, `Large`, `Playground`(HM 전용). - verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Disabled`, `Readonly`, `Sizes`("Compare Sizes"), `Small`, `Medium`, `Large`, `Playground`(HM 전용).
- Error Message·Required 표시는 Story 안의 조합 예시이며 Input 컴포넌트의 일부가 아니다. - Error Message·Required 표시는 Story 안의 조합 예시이며 Input 컴포넌트의 일부가 아니다.
- sidebar는 `All` 하나만 보이는 1뎁스. 운영 규칙은 지침 §6.3, §7 참조. - sidebar는 `All` 하나만 보이는 1뎁스. 운영 규칙은 지침 §6.3, §7 참조.
+2 -2
View File
@@ -72,8 +72,8 @@ Radix 구조·동작(hover open delay, Arrow 키와 Tab 이동, Escape 닫힘, f
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Navigation Menu"): Variants 한 구역에 `Viewport(기본)`과 `viewport={false}` 두 구성을 PC 균등 2열로 둔다(2026-11-09 Overview 제거, 닫힌 메뉴용 min-height 256px는 PC에서만 해제하고 1000px 미만 1열에서는 유지). 제품(설명 있는 3 링크), 자료(2열 4 링크), 회사(현재 위치 링크), 문의(일반 링크), 준비 중(disabled Trigger). - 대표 Story `All`("Navigation Menu"): Variants 한 구역에 "Viewport"(기본)와 "No viewport"(`viewport={false}`) 두 구성을 PC 균등 2열로 둔다(2026-11-09 Overview 제거, 닫힌 메뉴용 min-height 256px는 PC에서만 해제하고 1000px 미만 1열에서는 유지). 제품(설명 있는 3 링크), 자료(2열 4 링크), 회사(현재 위치 링크), 문의(일반 링크), 준비 중(disabled Trigger).
- verification: `Default`, `No Viewport`. - verification: `Default`, `No Viewport`, `Indicator Alignment`.
- Story의 Content 폭은 `style` 인라인으로 지정한다(새 Story 파일의 Tailwind 임의값 클래스가 개발 서버에서 생성되지 않는 일이 있었다). - Story의 Content 폭은 `style` 인라인으로 지정한다(새 Story 파일의 Tailwind 임의값 클래스가 개발 서버에서 생성되지 않는 일이 있었다).
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+2 -2
View File
@@ -9,7 +9,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Radio Group (Radix `RadioGroup`) | | 기반 | shadcn/ui Radio Group (Radix `RadioGroup`) |
| 대표 Story | `Components/Form/Radio Group` → `All` (이름 "Radio Group") | | 대표 Story | `Components/Form/Selection Controls` → `All`의 상태 매트릭스 Radio Group 열(Default·Hover·Focus·Disabled 항목/그룹·Validation)과 Variants의 Vertical·Horizontal(사이드바 `선택 컨트롤`, `components-form-selection-controls--all`). 기존 `Components/Form/Radio Group` → `All`(`components-form-radio-group--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) Focus 칸의 링은 Storybook 전용 `data-sb-focus`(`.storybook/pseudo-states.ts`, 2026-11-13)로 고정해 보여 주며 실제 Tab 포커스 동작은 같다. |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -57,7 +57,7 @@ shadcn `size-4 border-input text-primary shadow-xs dark:` class 제거 → theme
## 9. Storybook ## 9. Storybook
대표 Story `All`: Variants(`vertical`, `horizontal`; 2026-11-09 Overview 제거로 Vertical이 Variants 첫 항목), States(`hover`, `focus`, `disabled-item`, `disabled-group`), Validation(`error`). Focus는 `play`로 실제 focus. `hover`는 첫 항목을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 RadioGroupItem API가 아니다. verification: `Vertical`, `Horizontal`, `Disabled`, `Error`. 숨김 기존 `All`(URL 직접 접근용; 사이드바 대표는 Selection Controls, §표 참고): Variants(`vertical`, `horizontal`; 2026-11-09 Overview 제거로 Vertical이 Variants 첫 항목), States(`hover`, `focus`, `disabled-item`, `disabled-group`), Validation(`error`). Focus는 `play`로 실제 focus. `hover`는 첫 항목을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 RadioGroupItem API가 아니다. verification: `Vertical`, `Horizontal`, `Disabled`, `Error`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1 -1
View File
@@ -11,7 +11,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Sheet (Radix `Dialog`) | | 기반 | shadcn/ui Sheet (Radix `Dialog`) |
| 대표 Story | `Components/Overlay/Sheet` → `All` (이름 "Sheet") | | 대표 Story | `Components/Overlay/Sheet` → `All` (이름 "Sheet", 사이드바 `시트·드로어`, Drawer 예제 포함) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
+1 -1
View File
@@ -9,7 +9,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-03 | | 기준일 | 2026-10-03 |
| 기반 | shadcn/ui Sonner (`sonner` 2.0.8) | | 기반 | shadcn/ui Sonner (`sonner` 2.0.8) |
| 대표 Story | `Components/Feedback/Sonner` → `All` (이름 "Toast") | | 대표 Story | `Components/Feedback/Sonner` → `All` (이름 "Sonner", 사이드바 `토스트 알림`) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
+2 -2
View File
@@ -9,7 +9,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Switch (Radix `Switch`) | | 기반 | shadcn/ui Switch (Radix `Switch`) |
| 대표 Story | `Components/Form/Switch` → `All` (이름 "Switch") | | 대표 Story | `Components/Form/Selection Controls` → `All`의 상태 매트릭스 Switch 열(Default·Hover·Focus·Disabled, Validation 없음)(사이드바 `선택 컨트롤`, `components-form-selection-controls--all`). 기존 `Components/Form/Switch` → `All`(`components-form-switch--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) Focus 칸의 링은 Storybook 전용 `data-sb-focus`(`.storybook/pseudo-states.ts`, 2026-11-13)로 고정해 보여 주며 실제 Tab 포커스 동작은 같다. |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -56,7 +56,7 @@ shadcn의 `h-[1.15rem] w-8`, `border`, `shadow-xs`, `dark:` class 제거 → the
## 9. Storybook ## 9. Storybook
대표 Story `All`: States 한 구역에 `off`, `on`, `hover`, `focus`, `disabled`(2026-11-09 Overview 제거, PC 5열; 2026-10-22 `interactive` 구역 제거, 모든 예시가 실제 클릭으로 토글). `hover`는 Off·On을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 Switch API가 아니다. Focus는 `play`로 실제 focus. verification: `Off`, `On`, `Disabled`. 숨김 기존 `All`(URL 직접 접근용; 사이드바 대표는 Selection Controls, §표 참고): States 한 구역에 `off`, `on`, `hover`, `focus`, `disabled`(2026-11-09 Overview 제거, PC 5열; 2026-10-22 `interactive` 구역 제거, 모든 예시가 실제 클릭으로 토글). `hover`는 Off·On을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 Switch API가 아니다. Focus는 `play`로 실제 focus. verification: `Off`, `On`, `Disabled`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+3 -3
View File
@@ -32,7 +32,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@hm-design/ui"
| 구현 상태 | 기본 Primary Fill + 명시적 Secondary Fill + Legacy non-fill + 넘침 이동 버튼(D-090) 구현 (`hm-tab` class 없음) | | 구현 상태 | 기본 Primary Fill + 명시적 Secondary Fill + Legacy non-fill + 넘침 이동 버튼(D-090) 구현 (`hm-tab` class 없음) |
| 기준일 | 2026-10-09 (D-01 조합·D-02 상단 White 80% 반영, API는 2026-10-08 기본값 유지) | | 기준일 | 2026-10-09 (D-01 조합·D-02 상단 White 80% 반영, API는 2026-10-08 기본값 유지) |
| 기반 | shadcn/ui Tabs (Radix `Tabs`) | | 기반 | shadcn/ui Tabs (Radix `Tabs`) |
| 대표 Story | `Components/Navigation/Tabs` → `All` (이름 "Tabs"): Primary(Fill + content) / Secondary(Fill + surface) | | 대표 Story | `Components/Navigation/Tabs` → `All` (이름 "Tabs"): Variants Primary(Fill + content) / Secondary(Fill + surface), Behavior Overflow, Usage |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -126,13 +126,13 @@ Radix Tabs 구조·ARIA(`role=tablist/tab/tabpanel`, `aria-selected`)·roving ta
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Tabs"): **Variants**는 Primary(fill / panelSurface를 생략한 기본)·Secondary(`panelSurface="surface"`)·Overflow(20개 탭, 이전 States의 Overflow 예시)를 PC 균등 3열로 두고, **Usage**는 Card 내부 · Secondary / 페이지 직접 배치 · Primary 2열이다(2026-11-09 Overview·States 구역을 Variants로 통합, 이 페이지의 위아래 패딩만 줄여 1280×900 한 화면). 같은 Primary가 반복되던 **Default API** 구역(`fill`만, `fill` + `panelSurface="content"` 명시)은 2026-10-22 숨김 verification `DefaultApi`로 옮겼다. 기존 대표 Story ID와 메뉴를 유지한다. Hover는 실제 포인터로 확인한다. - 대표 Story `All`("Tabs"): **Variants**는 Primary(fill / panelSurface를 생략한 기본)·Secondary(`panelSurface="surface"`) 균등 2열, **Behavior**는 Overflow(20개 탭, 이전 States의 Overflow 예시, 이전/다음 이동 버튼·내부 스크롤) 1개를 같은 2열 grid의 왼쪽 열에, **Usage**는 Card 내부 · Secondary / 페이지 직접 배치 · Primary 균등 2열이다. 세 구역은 같은 grid(24px 간격)를 쓰며, 섹션 본문의 실제 콘텐츠 너비(컨테이너 쿼리, 공통 board 들여쓰기 제외)가 720px 이상이면 2열, 미만이면 1열이다. 섹션 제목·예제 라벨·간격은 공통 `StorySection` / `StateBoard`를 따른다(2026-11-13 Overflow를 Variants에서 Behavior로 분리, 예제 내용 불변. 이전: 2026-11-09 Overview·States 구역을 Variants로 통합). 같은 Primary가 반복되던 **Default API** 구역(`fill`만, `fill` + `panelSurface="content"` 명시)은 2026-10-22 숨김 verification `DefaultApi`로 옮겼다. 기존 대표 Story ID와 메뉴를 유지한다. Hover는 실제 포인터로 확인한다.
- verification(`tags: ["verification"]`): `DefaultApi`, `Secondary`, `SelectedFirst`, `SelectedLast`, `Focus`, `Disabled`, `Overflow`, `SecondaryOverflow`, `LongLabel`. Primary 기준이며 Secondary는 같은 CSS 경로를 쓴다. Focus는 `play`로 실제 focus를 준다. - verification(`tags: ["verification"]`): `DefaultApi`, `Secondary`, `SelectedFirst`, `SelectedLast`, `Focus`, `Disabled`, `Overflow`, `SecondaryOverflow`, `LongLabel`. Primary 기준이며 Secondary는 같은 CSS 경로를 쓴다. Focus는 `play`로 실제 focus를 준다.
- `LongLabel`은 짧은 라벨, 긴 라벨 1개, 긴 라벨 여러 개, 260px 좁은 container를 함께 검증한다. 라벨은 `nowrap`·무말줄임이며 실제 필요 폭이 넘을 때 이동 버튼으로 전환한다. - `LongLabel`은 짧은 라벨, 긴 라벨 1개, 긴 라벨 여러 개, 260px 좁은 container를 함께 검증한다. 라벨은 `nowrap`·무말줄임이며 실제 필요 폭이 넘을 때 이동 버튼으로 전환한다.
- non-fill Story(`Selected`, `SelectedMiddle`, `Fill`, `FillContent`, `FillOverflow`와 대표의 non-fill 구역)는 2026-10-06에 제거했다. API(`fill=false`, Legacy profile CSS)는 유지한다(§10). - non-fill Story(`Selected`, `SelectedMiddle`, `Fill`, `FillContent`, `FillOverflow`와 대표의 non-fill 구역)는 2026-10-06에 제거했다. API(`fill=false`, Legacy profile CSS)는 유지한다(§10).
- 2026-10-08 숨김 `Compatibility`(`verification` 태그 + **VERIFICATION ONLY** 표시)는 Legacy `fill={false}` / `fill={false} panelSurface="surface"` 동일성과 `panelSurface="surface"`만 명시한 경우의 Secondary 전환을 확인한다. 일반 Story 메뉴를 추가하지 않는다. - 2026-10-08 숨김 `Compatibility`(`verification` 태그 + **VERIFICATION ONLY** 표시)는 Legacy `fill={false}` / `fill={false} panelSurface="surface"` 동일성과 `panelSurface="surface"`만 명시한 경우의 Secondary 전환을 확인한다. 일반 Story 메뉴를 추가하지 않는다.
- Variants / Usage, `DefaultApi` 및 Fill verification 예제의 콘텐츠 panel은 `px-0 py-6`로 통일해 D-01을 실제로 반영한다. Primary는 상위 content 면, Secondary는 상위 surface 면에 연결하며 내부 패널의 별도 border·배경·radius를 제거한다. Compatibility의 Legacy non-fill 두 예제만 이전 `border bg-card rounded-b p-4`를 보존한다. - Variants / Usage, `DefaultApi` 및 Fill verification 예제의 콘텐츠 panel은 `px-0 py-6`로 통일해 D-01을 실제로 반영한다. Primary는 상위 content 면, Secondary는 상위 surface 면에 연결하며 내부 패널의 별도 border·배경·radius를 제거한다. Compatibility의 Legacy non-fill 두 예제만 이전 `border bg-card rounded-b p-4`를 보존한다.
- **Usage**(2026-10-09 추가)는 공개 Card / Input / Textarea / Table을 조합해 Card 내부 Secondary와 페이지 content 면 위 Primary를 보여준다. 두 예제 모두 `TabsContent`는 `px-0 py-6`이며 별도 배경·border·radius를 추가하지 않는다. 대표 Story ID·기존 구역·verification Story는 유지한다. - **Usage**(2026-10-09 추가)는 공개 Card / Input / Textarea / Table을 조합해 Card 내부 Secondary와 페이지 content 면 위 Primary를 보여준다. 두 예제 모두 `TabsContent`는 좌우 16px·상단 12px·하단 24px(`px-4 pt-3 pb-6`, 2026-11-13)이며 Card 예제의 Card / CardContent 여백은 그대로 둔다. 패널은 별도 배경·border·radius를 추가하지 않는다. 대표 Story ID·기존 구역·verification Story는 유지한다.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+3 -3
View File
@@ -9,7 +9,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Toggle (Radix `Toggle`) | | 기반 | shadcn/ui Toggle (Radix `Toggle`) |
| 대표 Story | `Components/Form/Toggle` → `All` (이름 "Toggle") | | 대표 Story | `Components/Form/Button` → `All`의 "Toggle Button" 섹션(H3 Variants / States · Groups · Sizes, 사이드바 `버튼`). 기존 `Components/Form/Toggle` → `All`(`components-form-toggle--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -69,9 +69,9 @@ Radix 구조·`aria-pressed`·`data-state`·Enter/Space·controlled/uncontrolled
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Toggle"): Variants(Off/On, default·outline)와 States(Disabled off/on), 단독 · 연결 그룹(단독 Toggle, `spacing=0` ToggleGroup 단일·다중 선택), Sizes(2026-11-09 Overview를 Variants로 바꾸고 PC 3열 기준으로 재배치). 2026-10-22 Icon-only·icon + text 예시는 숨김 `IconOnly`로만 확인하고, Controlled(`pressed` 표시)·className 확장 예시는 대표 화면에서 제거했다. `pressed` / `onPressedChange`와 `className` 확장 API(§5)는 그대로다. - 대표 Story `All`("Toggle"): Variants(Off/On, default·outline)와 States(Disabled: Off/On — 2026-11-13 행 라벨과 반복되던 "Disabled" 글자 제거), 단독 · 연결 그룹(단독 Toggle, `spacing=0` ToggleGroup 단일·다중 선택), Sizes(2026-11-09 Overview를 Variants로 바꾸고 PC 3열 기준으로 재배치). 2026-10-22 Icon-only·icon + text 예시는 숨김 `IconOnly`로만 확인하고, Controlled(`pressed` 표시)·className 확장 예시는 대표 화면에서 제거했다. `pressed` / `onPressedChange`와 `className` 확장 API(§5)는 그대로다.
- 2026-11-12 폼 요소 전시 통일: 나란히 놓인 토글 간격을 Button Story와 같은 8px로 맞췄다(Off/On·Disabled·Sizes·숨김 `IconOnly`, 기존 12px). `단독` 예시는 열 너비로 늘어나지 않고 Toggle 자체 너비를 유지한다. 상단 Variants·States는 기존 3열 중 두 칸 배치 대신 2열로 바꿔 전체 폭을 각 행의 한 줄 폭 비율(324:234, 최소 max-content)로 나눈다. 미리보기 폭 1000px 이상(브라우저 1280px + LNB 180px 포함)에서 Variants 4개가 한 줄로 유지되고, 그 미만은 1열로 쌓여 자연스럽게 줄바꿈한다. 이 때문에 States 시작 위치는 아래 `단독 · 연결 그룹` 3열과 맞추지 않는다. Toggle·토큰·API는 변경 없다. - 2026-11-12 폼 요소 전시 통일: 나란히 놓인 토글 간격을 Button Story와 같은 8px로 맞췄다(Off/On·Disabled·Sizes·숨김 `IconOnly`, 기존 12px). `단독` 예시는 열 너비로 늘어나지 않고 Toggle 자체 너비를 유지한다. 상단 Variants·States는 기존 3열 중 두 칸 배치 대신 2열로 바꿔 전체 폭을 각 행의 한 줄 폭 비율(324:234, 최소 max-content)로 나눈다. 미리보기 폭 1000px 이상(브라우저 1280px + LNB 180px 포함)에서 Variants 4개가 한 줄로 유지되고, 그 미만은 1열로 쌓여 자연스럽게 줄바꿈한다. 이 때문에 States 시작 위치는 아래 `단독 · 연결 그룹` 3열과 맞추지 않는다. Toggle·토큰·API는 변경 없다.
- verification: `States`, `Icon Only`, `Disabled`. - verification: `Toggle`(기존 `All`, 사이드바 숨김), `States`, `Icon Only`, `Disabled`, `Content Nodes`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1 -1
View File
@@ -9,7 +9,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Tooltip (Radix `Tooltip`) | | 기반 | shadcn/ui Tooltip (Radix `Tooltip`) |
| 대표 Story | `Components/Overlay/Tooltip` → `All` (이름 "Tooltip") | | 대표 Story | `Components/Overlay/Tooltip` → `All` (이름 "Tooltip", 사이드바 `툴팁·호버 카드`, Hover Card 예제 포함) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
+1 -1
View File
@@ -157,7 +157,7 @@ HM 지정값이 있는 색상, radius, control height, typography 등은 Theme T
- 컴포넌트가 실제 지원하는 default, variant, size, disabled 및 주요 상태를 확인한다. invalid, open 등 해당 컴포넌트에 실제로 있는 상태만 다루고, 없는 상태를 문서화를 위해 만들지 않는다. - 컴포넌트가 실제 지원하는 default, variant, size, disabled 및 주요 상태를 확인한다. invalid, open 등 해당 컴포넌트에 실제로 있는 상태만 다루고, 없는 상태를 문서화를 위해 만들지 않는다.
- Storybook은 구현 Reference Catalog이자 컴포넌트 단위의 검수와 사용법 문서화 도구다. 신규 UI 구현 전 유사 대표/PRIMARY Story를 먼저 확인한다. `Demo/App` 화면은 여러 컴포넌트를 조합한 적용 결과 확인용이며, Demo의 페이지·타일 UI를 Story의 기준으로 쓰지 않는다. - Storybook은 구현 Reference Catalog이자 컴포넌트 단위의 검수와 사용법 문서화 도구다. 신규 UI 구현 전 유사 대표/PRIMARY Story를 먼저 확인한다. `Demo/App` 화면은 여러 컴포넌트를 조합한 적용 결과 확인용이며, Demo의 페이지·타일 UI를 Story의 기준으로 쓰지 않는다.
- 컴포넌트 Story는 현재 HM 대표 Story의 공통 구조를 따른다. 컴포넌트 이름의 대표 Story 하나에 종류·크기·상태를 한 화면으로 모으고, 같은 구역을 `verification` 태그 Story(예: Roles, Sizes, Brand States)로도 둔다. `verification` Story는 메뉴와 검색에서 기본으로 숨기고(`.storybook/main.ts`의 `tags`) 직접 URL과 사이드바 태그 필터로 연다. HM 전용 Story(Playground, Compatibility)도 같은 태그로 숨긴다. 기본 사이드바에는 컴포넌트가 하위 메뉴 없는 1뎁스 항목 하나로만 보이고(대표 Story 하나만 노출되면 Storybook이 컴포넌트 항목에 합친다), 클릭하면 한 페이지에서 전체 검수 내용이 열린다. Story 이름과 순서, 행·열 구성은 현재 HM 유사 대표 Story의 공통 구성을 먼저 따른다. 외부 ui_viz Story는 제공된 경우에만 충돌하지 않는 참고로 사용한다. - 컴포넌트 Story는 현재 HM 대표 Story의 공통 구조를 따른다. 컴포넌트 이름의 대표 Story 하나에 종류·크기·상태를 한 화면으로 모으고, 같은 구역을 `verification` 태그 Story(예: Roles, Sizes, Brand States)로도 둔다. `verification` Story는 메뉴와 검색에서 기본으로 숨기고(`.storybook/main.ts`의 `tags`) 직접 URL과 사이드바 태그 필터로 연다. HM 전용 Story(Playground, Compatibility)도 같은 태그로 숨긴다. 기본 사이드바에는 컴포넌트가 하위 메뉴 없는 1뎁스 항목 하나로만 보이고(대표 Story 하나만 노출되면 Storybook이 컴포넌트 항목에 합친다), 클릭하면 한 페이지에서 전체 검수 내용이 열린다. Story 이름과 순서, 행·열 구성은 현재 HM 유사 대표 Story의 공통 구성을 먼저 따른다. 외부 ui_viz Story는 제공된 경우에만 충돌하지 않는 참고로 사용한다.
- Storybook 페이지 프레임(2026-11-11 확정)은 `src/components/story-page.tsx`·`story-page.css`의 `StoryPage`·`StoryPageHeader`·`StorySection`·`StorySectionGrid`가 공통으로 소유한다. 2026-11-11 사용자 요청으로 `StoryPageHeader`는 H1 페이지 제목·kicker·설명·SW 공통 안내 문구를 표시하지 않으며(호출과 props는 유지), 페이지는 위 여백(16–24px) 바로 아래 첫 섹션부터 시작한다. 단, Preview 문서 제목이 Story ID라 스크린 리더가 페이지를 식별할 수 있도록 `title`만 시각적으로 숨긴 접근성 전용 H1(`.sb-page-a11y-title`, 1px clip·문서 흐름 밖)로 렌더링한다. 검증 스크립트는 이 H1의 시각 스타일에 의존하지 않고 첫 콘텐츠 블록(`.sb-page-inner > :not(h1)`)으로 렌더링을 기다리며, 페이지 식별은 H1 텍스트로 한다. 제품 `PageHeader` 컴포넌트와는 무관하다. Web·Chart·OrgChart(Admin)의 문서 위계는 H2 섹션 제목 16/24/700 → 12px → 얇은 구분선 → 16px → 콘텐츠이며, 섹션 사이는 28px(`--sb-page-section-gap`; `StorySectionGrid`의 column gap 24px은 별도 유지), `StateBoard`의 H3 라벨은 14/20/500 보조색이고 자체 구분선을 두지 않으며 행 사이 24px이다. 구분선 아래 문서 텍스트·사양 표 같은 정보 블록만 `.sb-page-indent`(16px, 640px 이하 12px)로 묶음 단위로 들여쓰고, 실제 컴포넌트·Pattern·Chart·OrgChart·SW·CENTER 미리보기와 테두리 있는 카드/칩 Grid는 섹션 왼쪽 기준선에 둔다. 예외로 그룹 보드(H3 그룹 라벨 + 해당 예제)는 같은 들여쓰기(`--sb-page-indent`)를 받아 섹션 제목(L1) → 그룹 라벨(L2) → 예제(L3) 위계를 만든다. `StateBoard`(`StorySection` 안)와 Card의 `CardBoard`는 `data-sb-board`로 표시만 하고, `story-page.css`가 섹션 본문의 직계 자식 중 보드이거나 보드를 품은 블록 하나에만 들여쓰기를 적용한다. 그래서 Story 전용 Grid의 열마다 단일 그룹 보드를 둔 구성도 Grid 전체가 한 번만 들여써지고 열 간격 24px이 유지된다(열별 중복 들여쓰기 금지). 보드가 없는 Sizes Grid(Input·Select·Toggle)는 Grid에 `.sb-page-indent`를 붙이며, Input·Select의 `Small / Medium / Large`는 실제 필드 Label(Primary)로 유지한다. 전체 폭 Pattern 미리보기(GNB·LNB·Loading Screen·Page Header)는 `indent={false}`로 섹션 기준선을 유지하고, 그룹 라벨 없이 예제만 있는 Toggle Group(내부 검증용)과 단일 미리보기(Menubar·Dropdown Menu·Context Menu 등)는 들여쓰지 않는다. 위·아래 여백은 16–24px, 좌우 여백은 기존 값(480px까지 16px, 768px부터 60px)을 유지한다. 값은 기존 Component Token 또는 Storybook 전용 px 값을 `--sb-page-*`로 이름 붙인 것이며 새 Design Token이 아니다. 섹션을 나란히 놓을 때는 `StorySectionGrid`를 쓰고, Story별 `!important`·`[&_.sb-page-*]` 덮어쓰기로 간격을 바꾸지 않는다. SW와 노선도(CENTER)는 이 페이지 프레임과 섹션 간격만 공유하고 컴포넌트 크기·글꼴·캔버스·카드 규격은 각자 유지한다. 페이지 제목은 사이드바 이름을 따르며, 한 그룹에 Story가 여럿이면 `그룹 — Story 이름`(예: `SW Header — 화면 너비`)으로 쓴다. 섹션 제목은 페이지 제목과 예제 카드 제목을 반복하지 않는 짧은 변형명(예: Chart `Basic`, `Label`, `Solid`)으로 쓴다. - Storybook 페이지 프레임(2026-11-11 확정)은 `src/components/story-page.tsx`·`story-page.css`의 `StoryPage`·`StoryPageHeader`·`StorySection`·`StorySectionGrid`가 공통으로 소유한다. 2026-11-11 사용자 요청으로 `StoryPageHeader`는 H1 페이지 제목·kicker·설명·SW 공통 안내 문구를 표시하지 않으며(호출과 props는 유지), 페이지는 위 여백(16–24px) 바로 아래 첫 섹션부터 시작한다. 단, Preview 문서 제목이 Story ID라 스크린 리더가 페이지를 식별할 수 있도록 `title`만 시각적으로 숨긴 접근성 전용 H1(`.sb-page-a11y-title`, 1px clip·문서 흐름 밖)로 렌더링한다. 검증 스크립트는 이 H1의 시각 스타일에 의존하지 않고 첫 콘텐츠 블록(`.sb-page-inner > :not(h1)`)으로 렌더링을 기다리며, 페이지 식별은 H1 텍스트로 한다. 제품 `PageHeader` 컴포넌트와는 무관하다. Web·Chart·OrgChart(Admin)의 문서 위계는 H2 섹션 제목 16/24/700 → 12px → 콘텐츠이며(2026-11-13 제목 아래 얇은 구분선과 16px 간격 제거), 섹션 사이는 28px(`--sb-page-section-gap`; `StorySectionGrid`의 column gap 24px은 별도 유지), `StateBoard`의 H3 라벨은 14/20/500 보조색이고 자체 구분선을 두지 않으며 행 사이 24px이다. 섹션 본문의 문서 텍스트·사양 표 같은 정보 블록만 `.sb-page-indent`(16px, 640px 이하 12px)로 묶음 단위로 들여쓰고, 실제 컴포넌트·Pattern·Chart·OrgChart·SW·CENTER 미리보기와 테두리 있는 카드/칩 Grid는 섹션 왼쪽 기준선에 둔다. 예외로 그룹 보드(H3 그룹 라벨 + 해당 예제)는 같은 들여쓰기(`--sb-page-indent`)를 받아 섹션 제목(L1) → 그룹 라벨(L2) → 예제(L3) 위계를 만든다. `StateBoard`(`StorySection` 안)와 Card의 `CardBoard`는 `data-sb-board`로 표시만 하고, `story-page.css`가 섹션 본문의 직계 자식 중 보드이거나 보드를 품은 블록 하나에만 들여쓰기를 적용한다. 그래서 Story 전용 Grid의 열마다 단일 그룹 보드를 둔 구성도 Grid 전체가 한 번만 들여써지고 열 간격 24px이 유지된다(열별 중복 들여쓰기 금지). 보드가 없는 Sizes Grid(Input·Select·Toggle)는 Grid에 `.sb-page-indent`를 붙이며, Input·Select의 `Small / Medium / Large`는 실제 필드 Label(Primary)로 유지한다. 전체 폭 Pattern 미리보기(GNB·LNB·Loading Screen·Page Header)는 `indent={false}`로 섹션 기준선을 유지하고, 그룹 라벨 없이 예제만 있는 Toggle Group(내부 검증용)과 단일 미리보기(Menubar·Dropdown Menu·Context Menu 등)는 들여쓰지 않는다. 위·아래 여백은 16–24px, 좌우 여백은 480px까지 16px, 768px부터 30px이다(2026-11-13 최대 60px → 30px, 180px LNB와 함께). 값은 기존 Component Token 또는 Storybook 전용 px 값을 `--sb-page-*`로 이름 붙인 것이며 새 Design Token이 아니다. 섹션을 나란히 놓을 때는 `StorySectionGrid`를 쓰고, Story별 `!important`·`[&_.sb-page-*]` 덮어쓰기로 간격을 바꾸지 않는다. SW와 노선도(CENTER)는 이 페이지 프레임과 섹션 간격만 공유하고 컴포넌트 크기·글꼴·캔버스·카드 규격은 각자 유지한다. 페이지 제목은 사이드바 이름을 따르며, 한 그룹에 Story가 여럿이면 `그룹 — Story 이름`(예: `SW Header — 화면 너비`)으로 쓴다. 섹션 제목은 페이지 제목과 예제 카드 제목을 반복하지 않는 짧은 변형명(예: Chart `Basic`, `Label`, `Solid`)으로 쓴다.
- 대표 Story의 Focus 구역은 pseudo-state 복제 대신 `play` 함수로 실제 요소에 focus를 준다(ui_viz도 Focus는 정적 specimen을 만들지 않는다). hover 같은 상태가 ui_viz에 없으면 `.storybook/pseudo-states.ts`를 쓰지 않는다. 구역 배치 helper는 `src/components/story-board.tsx`의 `StateBoard`를 쓴다. - 대표 Story의 Focus 구역은 pseudo-state 복제 대신 `play` 함수로 실제 요소에 focus를 준다(ui_viz도 Focus는 정적 specimen을 만들지 않는다). hover 같은 상태가 ui_viz에 없으면 `.storybook/pseudo-states.ts`를 쓰지 않는다. 구역 배치 helper는 `src/components/story-board.tsx`의 `StateBoard`를 쓴다.
- hover, pressed 같은 pseudo state는 Storybook이 `:hover`/`:active` 규칙을 `data-sb-hover`/`data-sb-active` 규칙으로 복제해 고정한다(`.storybook/pseudo-states.ts`). 복제만 하고 값을 새로 정의하지 않으므로 실제 디자인을 보정하는 용도로 쓰지 않는다. Story에서는 `data-sb-*` 속성만 붙인다. - hover, pressed 같은 pseudo state는 Storybook이 `:hover`/`:active` 규칙을 `data-sb-hover`/`data-sb-active` 규칙으로 복제해 고정한다(`.storybook/pseudo-states.ts`). 복제만 하고 값을 새로 정의하지 않으므로 실제 디자인을 보정하는 용도로 쓰지 않는다. Story에서는 `data-sb-*` 속성만 붙인다.
- ui_viz에서 값이 `TBD`인 상태(예: Button Focus)는 별도 specimen을 만들지 않고 키보드로 확인하는 안내만 둔다. ui_viz에 없는 상태(예: Button `aria-invalid`)와 `REFERENCE_ONLY` 항목은 검수 Story에 넣지 않는다. shadcn 호환 API는 `Compatibility` Story로 분리한다. - ui_viz에서 값이 `TBD`인 상태(예: Button Focus)는 별도 specimen을 만들지 않고 키보드로 확인하는 안내만 둔다. ui_viz에 없는 상태(예: Button `aria-invalid`)와 `REFERENCE_ONLY` 항목은 검수 Story에 넣지 않는다. shadcn 호환 API는 `Compatibility` Story로 분리한다.
+2 -2
View File
@@ -19,7 +19,7 @@ Implementation: current @hm-design/ui primitives / patterns (HM Collapsible + pl
|---|---| |---|---|
| 구현 상태 | 구현 완료 (2026-10-03), 3뎁스 추가 (2026-10-06) | | 구현 상태 | 구현 완료 (2026-10-03), 3뎁스 추가 (2026-10-06) |
| 기반 | HM Collapsible + 일반 요소 (새 dependency 없음) | | 기반 | HM Collapsible + 일반 요소 (새 dependency 없음) |
| 대표 Story | `Patterns/Layout/LNB` → `LNB` (States: Selected·Expanded·3뎁스 Selected, Variants: With Logo·Long Labels. Scroll·Standalone 반응형은 2026-11-09 대표 페이지에서 분리되어 아래 verification Story로 확인; verification: Third Level, Third Level Long Labels, Default, Selected, Expanded, Selected Child, Collapsed With Selection, With Logo, Long Labels, Scroll, Third Level Scroll, VERIFICATION ONLY · GNB + LNB, LNB Standalone Desktop, LNB Standalone Mobile, LNB Standalone Mobile Menu Open) | | 대표 Story | `Patterns/Layout/LNB` → `LNB` (States 한 구역: Selected·Expanded·3뎁스 Selected. With Logo·Long Labels·Scroll·Standalone 반응형은 2026-11-09 대표 페이지에서 분리되어 아래 verification Story로 확인; verification: Third Level, Third Level Long Labels, Default, Selected, Expanded, Selected Child, Collapsed With Selection, With Logo, Long Labels, Scroll, Third Level Scroll, VERIFICATION ONLY · GNB + LNB, LNB Standalone Desktop, LNB Standalone Mobile, LNB Standalone Mobile Menu Open, LNB Profile Menu Open) |
| 보류 | §11 | | 보류 | §11 |
## 2. 목적 / 역할 ## 2. 목적 / 역할
@@ -178,7 +178,7 @@ LNB Mode가 Desktop Brand/Profile과 Mobile Header/Sheet Profile을 담당한다
| `--hm-lnb-width` | 240px (1023px 이하에서 0px) | | `--hm-lnb-width` | 240px (1023px 이하에서 0px) |
| `--hm-lnb-join` | 18px. 콘텐츠 둥근 모서리·bleed | | `--hm-lnb-join` | 18px. 콘텐츠 둥근 모서리·bleed |
| `--hm-lnb-background` / `-background-end` | 5-stop gradient / `#266651` | | `--hm-lnb-background` / `-background-end` | 5-stop gradient / `#266651` |
| item | 1뎁스 min-height 48(`--hm-lnb-item-min-height`) + padding-block 10(`--hm-lnb-item-padding-block`), 14/28/700(`--hm-lnb-item-font-size`), gap 12, 아이콘 20, 글자 테두리 1.5px | | item | 1뎁스 min-height 48(`--hm-lnb-item-min-height`) + padding-block 10(`--hm-lnb-item-padding-block`), padding-start 12(`--hm-lnb-item-padding-start`, 2026-11-12 이전 18. Storybook 사이드바 `10 32 10 12`와 같음), 오른쪽 32(inset-end 20 + 12), 14/28/700(`--hm-lnb-item-font-size`), gap 12, 아이콘 20, 글자 테두리 1.5px |
| `--hm-lnb-subitem-*` | 2뎁스(그룹 안 직속 링크·중첩 그룹 trigger) min-height 42(`-min-height`), padding-block 7(`-padding-block`), padding-start 68 | | `--hm-lnb-subitem-*` | 2뎁스(그룹 안 직속 링크·중첩 그룹 trigger) min-height 42(`-min-height`), padding-block 7(`-padding-block`), padding-start 68 |
| `--hm-lnb-profile-min-height` | 48px. Profile 높이는 3뎁스 행과 독립이다 | | `--hm-lnb-profile-min-height` | 48px. Profile 높이는 3뎁스 행과 독립이다 |
| current | `--hm-sem-bg-content` + `--hm-sem-action-primary`, radius 8 0 0 8 | | current | `--hm-sem-bg-content` + `--hm-sem-action-primary`, radius 8 0 0 8 |
@@ -353,7 +353,6 @@ export function CenterInformationDisplayReferenceDemo() {
<div className="cid-demo"> <div className="cid-demo">
<Block <Block
title="박스형 · 정의 목록 (박스형 설명1)" title="박스형 · 정의 목록 (박스형 설명1)"
note="배지 + 그라데이션 레이블 칩 + 값 블록; 하위 불릿은 값 열 유지"
> >
<CenterInformationBox <CenterInformationBox
layout="definition" layout="definition"
@@ -370,7 +369,7 @@ export function CenterInformationDisplayReferenceDemo() {
/> />
</Block> </Block>
<Block title="박스형 · 목록 — Source 팔레트 4종" note="지오메트리 동일; 팔레트만 다름"> <Block title="박스형 · 목록 — 팔레트 4종" note="지오메트리 동일; 팔레트만 다름">
{['navy', 'teal', 'orange', 'violet'].map((palette) => ( {['navy', 'teal', 'orange', 'violet'].map((palette) => (
<CenterInformationBox <CenterInformationBox
key={palette} key={palette}
@@ -382,7 +381,7 @@ export function CenterInformationDisplayReferenceDemo() {
))} ))}
</Block> </Block>
<Block title="테이블형" note="행 높이 = 기본값 + 추가 행마다 26.116 u + 추가 항목마다 34.364 u"> <Block title="테이블형">
<CenterInformationTable <CenterInformationTable
title="사업효과" title="사업효과"
width="643px" width="643px"
@@ -401,7 +400,7 @@ export function CenterInformationDisplayReferenceDemo() {
/> />
</Block> </Block>
<Block title="강조형 · 화살표 표시 (강조형 1)" note="패널은 카드 뒤에 4 u 겹침으로 배치되며 투명하게 사라짐"> <Block title="강조형 · 화살표 표시 (강조형 1)">
<CenterInformationEmphasis <CenterInformationEmphasis
title="사업목적" title="사업목적"
arrow arrow
@@ -429,7 +428,7 @@ export function CenterInformationDisplayReferenceDemo() {
/> />
</Block> </Block>
<Block title="동적 콘텐츠 — 짧은 / 기준 / 긴 / 매우 긴" note="소유 단일행은 말줄임으로 클리핑; 명시적 줄바꿈 유지; 장문이 지오메트리를 늘리지 않음"> <Block title="동적 콘텐츠 — 짧은 / 기준 / 긴 / 매우 긴" note="한 줄 항목은 말줄임, 명시적 줄바꿈 유지">
{CONTENT_STATES.map((state) => ( {CONTENT_STATES.map((state) => (
<div className="cid-demo__state" key={state.key} data-content-test={state.key}> <div className="cid-demo__state" key={state.key} data-content-test={state.key}>
<span className="cid-demo__tag">{state.label}</span> <span className="cid-demo__tag">{state.label}</span>
@@ -452,11 +452,6 @@ export function CenterSpatialInformationReferenceDemo() {
<div className="csi csi-demo"> <div className="csi csi-demo">
<Panel <Panel
id="csi-area-route" title="1. 노선 — 노선, 상태, 윤곽" 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={ note={
"KNOWN_DEVIATION — 구도,면도, 이도.svg는 흰색 윤곽으로 내보내며 명시된 Source 규칙과 시안 색상 견본에 모순" "KNOWN_DEVIATION — 구도,면도, 이도.svg는 흰색 윤곽으로 내보내며 명시된 Source 규칙과 시안 색상 견본에 모순"
+ "여기서는 명시된 규칙 구현" + "여기서는 명시된 규칙 구현"
@@ -479,7 +474,6 @@ export function CenterSpatialInformationReferenceDemo() {
<Panel <Panel
id="csi-area-project-route" title="1b. 사업 노선 — 사업노선과 끝 표시" id="csi-area-project-route" title="1b. 사업 노선 — 사업노선과 끝 표시"
legend={"사업 노선 #D21203. 원형 표시 = 선의 150%인 원 (18u 선에서 27u), 노선 윤곽과 그림자 공유."}
> >
<div className="csi-scale-50" style={{ padding: '10px 4px 4px' }}> <div className="csi-scale-50" style={{ padding: '10px 4px 4px' }}>
<ProjectRoute /> <ProjectRoute />
@@ -490,8 +484,6 @@ export function CenterSpatialInformationReferenceDemo() {
id="csi-area-route-information" title="2. 노선 정보 표기 — 노선번호와 노선명 (동적 텍스트)" id="csi-area-route-information" title="2. 노선 정보 표기 — 노선번호와 노선명 (동적 텍스트)"
legend={ legend={
'노선 식별에 필요한 경우 노선번호 또는 노선명을 선택하여 표기한다.' '노선 식별에 필요한 경우 노선번호 또는 노선명을 선택하여 표기한다.'
+ "아래 문자열은 모두 실제 DOM 텍스트다. Source SVG에는 윤곽 글리프 경로로 기록되어 있지만 해당 지오메트리는 런타임에서 사용하지 않음. "
+ "노선명 너비 = padding 9u + 고유 텍스트 너비 (높이 54u 고정). 노선번호 기호는 배지 기본 64u로 고정."
} }
note={ note={
"KNOWN_DEVIATION — 고속도로 노선명.svg 높이는 48u이며 고속도로/특별시도 이름 표면 (#2E619A / #9729E5)은 선 색상과 다름" "KNOWN_DEVIATION — 고속도로 노선명.svg 높이는 48u이며 고속도로/특별시도 이름 표면 (#2E619A / #9729E5)은 선 색상과 다름"
@@ -531,7 +523,6 @@ export function CenterSpatialInformationReferenceDemo() {
id="csi-area-direction" title="3. 방면 표기 — 방면 배지" id="csi-area-direction" title="3. 방면 표기 — 방면 배지"
legend={ legend={
'노선의 진행 방향을 안내할 경우 화살표와 방면명을 조합한 배지형으로 표기한다. 노선 유형과 관계없이 동일한 형태로 적용한다.' '노선의 진행 방향을 안내할 경우 화살표와 방면명을 조합한 배지형으로 표기한다. 노선 유형과 관계없이 동일한 형태로 적용한다.'
+ "완전히 둥근 50u 캡슐, 흰색 80%의 안쪽 윤곽 2u, 7점 화살표 다각형, 콘텐츠 기반 너비."
} }
> >
<div className="csi-scale-50" style={{ position: 'relative', padding: '4px 0 18px' }}> <div className="csi-scale-50" style={{ position: 'relative', padding: '4px 0 18px' }}>
@@ -422,8 +422,8 @@ function FacilityReferenceDemo({ config, alternateName }: { config: FacilityConf
return ( return (
<div className="facility-reference" data-reference-facility={config.id}> <div className="facility-reference" data-reference-facility={config.id}>
<div className="facility-reference__variant-grid"> <div className="facility-reference__variant-grid">
<Sample title="Position Map" note="로컬 SVG · 위치도"><LocalAssetFacility config={config} /></Sample> <Sample title="Position Map" note="위치도"><LocalAssetFacility config={config} /></Sample>
<Sample title="Actual / Detail" note="로컬 SVG · 실사화"><LocalAssetFacility config={config} variant="actualDetail" /></Sample> <Sample title="Actual / Detail" note="실사화"><LocalAssetFacility config={config} variant="actualDetail" /></Sample>
</div> </div>
{alternateName ? ( {alternateName ? (
@@ -435,14 +435,14 @@ function FacilityReferenceDemo({ config, alternateName }: { config: FacilityConf
</Sample> </Sample>
) : null} ) : null}
<Sample title="Separated" note="Source 작성 부분 간격"> <Sample title="Separated">
<div className="facility-reference__row"> <div className="facility-reference__row">
<LocalAssetFacility config={config} separated /> <LocalAssetFacility config={config} separated />
<LocalAssetFacility config={config} variant="actualDetail" separated /> <LocalAssetFacility config={config} variant="actualDetail" separated />
</div> </div>
</Sample> </Sample>
<Sample title="너비 동작" note="Source 정의 가변 영역만 확장"> <Sample title="너비 동작" note="가변 영역만 확장">
<div className="facility-reference__width-tests"> <div className="facility-reference__width-tests">
{widthNames.map((name, index) => ( {widthNames.map((name, index) => (
<div key={name}><span>{['SHORT', 'MEDIUM', 'LONG'][index]}</span><LocalAssetFacility config={config} name={name} /></div> <div key={name}><span>{['SHORT', 'MEDIUM', 'LONG'][index]}</span><LocalAssetFacility config={config} name={name} /></div>
@@ -473,7 +473,7 @@ export function RoadsideReferenceDemo() {
return ( return (
<div className="facility-reference roadside-reference" data-reference-facility="roadside"> <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"> <div className="roadside-tag-tests">
{tests.map((test) => ( {tests.map((test) => (
<div key={test.size} className="roadside-tag-test" data-roadside-test={test.size.toLowerCase()}> <div key={test.size} className="roadside-tag-test" data-roadside-test={test.size.toLowerCase()}>
@@ -484,7 +484,7 @@ export function RoadsideReferenceDemo() {
</div> </div>
</Sample> </Sample>
<Sample title="Actual / Detail" note="Source 작성 그라데이션/filter 그래픽 · 고정 양 끝 + 가변 중앙"> <Sample title="Actual / Detail" note="고정 양 끝 + 가변 중앙">
<div className="roadside-tag-tests"> <div className="roadside-tag-tests">
{tests.map((test) => ( {tests.map((test) => (
<div key={test.size} className="roadside-tag-test" data-roadside-actual-test={test.size.toLowerCase()}> <div key={test.size} className="roadside-tag-test" data-roadside-actual-test={test.size.toLowerCase()}>
@@ -495,11 +495,11 @@ export function RoadsideReferenceDemo() {
</div> </div>
</Sample> </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 /> <RoadsideFacilityTag entity="drowsy-shelter" variant="actualDetail" separated />
</Sample> </Sample>
<Sample title="연계 / 노선 맥락" note="노선 + 앵커 + 연결선 + 같은 시설물 태그 계약"> <Sample title="연계 / 노선 맥락" note="노선 + 앵커 + 연결선 + 시설물 태그">
<RoadsideAppliedExample /> <RoadsideAppliedExample />
</Sample> </Sample>
</div> </div>
@@ -178,15 +178,15 @@ export function TunnelReferenceDemo() {
return ( return (
<div className="tunnel-reference"> <div className="tunnel-reference">
<div className="tunnel-reference__variant-grid"> <div className="tunnel-reference__variant-grid">
<TunnelSample title="Position Map" note="로컬 SVG · 위치도"> <TunnelSample title="Position Map" note="위치도">
<TunnelFacility variant="positionMap" /> <TunnelFacility variant="positionMap" />
</TunnelSample> </TunnelSample>
<TunnelSample title="Actual / Detail" note="로컬 SVG · 실사화"> <TunnelSample title="Actual / Detail" note="실사화">
<TunnelFacility variant="actualDetail" /> <TunnelFacility variant="actualDetail" />
</TunnelSample> </TunnelSample>
</div> </div>
<TunnelSample title="Separated" note="같은 개체 · Source 작성 부분 간격"> <TunnelSample title="Separated">
<div className="tunnel-reference__row"> <div className="tunnel-reference__row">
<TunnelFacility variant="positionMap" separated /> <TunnelFacility variant="positionMap" separated />
<TunnelFacility variant="actualDetail" 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 색상/토큰을 전달합니다. - 색상을 지정하지 않으면 `--hm-chart-1`~`5`(HM 그린·브라운·웜 뉴트럴 팔레트 별칭)를 순서대로 사용합니다. 다른 색은 `color`에 CSS 색상/토큰을 전달합니다.
- 범례 라벨은 짧게 유지합니다. Bar·Line·Radar·Area의 범례는 한 줄로 두며, 직접 `flex-wrap`으로 줄바꿈시키면 Recharts가 범례 높이를 과대 측정해 차트 아래에 빈 공간이 생길 수 있습니다. Donut·Pie·Stacked Pie는 컴포넌트 내부에서 줄바꿈 범례를 쓰도록 구성되어 있고, Radial 계열은 범례를 차트 박스 밖 아래에 따로 놓습니다. - 범례 라벨은 짧게 유지합니다. Bar·Line·Radar·Area의 범례는 한 줄로 두며, 직접 `flex-wrap`으로 줄바꿈시키면 Recharts가 범례 높이를 과대 측정해 차트 아래에 빈 공간이 생길 수 있습니다. Donut·Pie·Stacked Pie는 컴포넌트 내부에서 줄바꿈 범례를 쓰도록 구성되어 있고, Radial 계열은 범례를 차트 박스 밖 아래에 따로 놓습니다.
- 모든 차트의 `label`은 필수 접근성 이름(`role="group"`의 `aria-label`)입니다. - 모든 차트의 `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. 렌더링 ## 4. 렌더링
@@ -6,7 +6,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { execFileSync } from 'node:child_process' import { execFileSync } from 'node:child_process'
import path from 'node:path' import path from 'node:path'
import http from 'node:http' 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' import { loadPlaywright } from './playwright-runtime.mjs'
// Property panel comparison consolidation (2026-10-19). // 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 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]') const scroller = document.querySelector('[data-compare-scroll]')
return { 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 }, 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 }, 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). // 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 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) 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 } 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 && 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]) && 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: 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') 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 // ---- 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-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 // 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. // (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) { 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 } 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 report.data[`gaps${width}`] = gaps
@@ -141,20 +145,30 @@ try {
report.data[`drop-${id}`] = info 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) 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`) }) 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. // 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) await page.waitForTimeout(150)
const after = await page.evaluate(() => { const after = await page.evaluate(() => {
const d = document.querySelector('.hm-sw-property__drop')?.getBoundingClientRect() const d = document.querySelector('.hm-sw-property__drop')?.getBoundingClientRect()
const f = document.querySelector('[data-compare="expanded"] [data-property-id="layer"] [aria-expanded="true"]')?.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 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) 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') 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. // 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', 'sw-viewer--compare-with-sw-header',
// 2026-10-22 (user request): SW/Popup keeps only Property Panel in the sidebar. // 2026-10-22 (user request): SW/Popup keeps only Property Panel in the sidebar.
'sw-popup--custom-content', 'sw-popup--multiple-popups'] 'sw-popup--custom-content', 'sw-popup--multiple-popups']
export const renamedEntries = { 'sw-header--overview': 'SW Header', 'sw-footer--full-footer': 'SW Footer', // 2026-11-13 (user request): the expected names follow the current approved Story names (Korean menu names); IDs unchanged.
'sw-header--logo-slot': 'Logo', // 2026-10-22 (user request): sidebar "Logo", page title "SW Header — 로고" export const renamedEntries = { 'sw-header--overview': 'SW Header', 'sw-footer--full-footer': 'SW 명령어바',
'sw-header--property-panel-compare': 'Property Panel', // 2026-10-22 (user request): was "Property Panel · 4개 비교" 'sw-header--logo-slot': '로고', // 2026-10-22 (user request): sidebar "Logo", page title "SW Header — 로고"; now 로고
'sw-footer--responsive': 'Responsive More', 'sw-viewer--content-variants': 'Settings Popup', 'sw-header--property-panel-compare': '속성창', // 2026-10-22 (user request): was "Property Panel · 4개 비교", then Property Panel
'sw-viewer--responsive': 'Responsive More' } '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. // 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, export const approvedAddedStoryIds = [...integratedStoryIds.filter(id => !retiredToolIds.includes(id)), ...homeMenuSidebarIds,
...Object.values(movedHomeMenuIds).filter(id => !retiredHomeMenuCompareIds.includes(id)), ...footerStoryIds, ...viewerStoryIds, ...themeStoryIds] ...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). // An earlier index entry with the approved tag / Popup move applied (2b0e1a2; the moved Story has no component path).
export const withApprovedEntryChanges = entry => { export const withApprovedEntryChanges = entry => {
if (renamedEntries[entry.id]) entry = { ...entry, name: renamedEntries[entry.id] } 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 (verificationTaggedIds.includes(entry.id)) return { ...entry, tags: [...entry.tags, 'verification'] }
if (!movedToPopupIds.includes(entry.id)) return entry if (!movedToPopupIds.includes(entry.id)) return entry
const { componentPath, ...rest } = entry const { componentPath, ...rest } = entry
@@ -145,6 +151,20 @@ export const movedHeaderDeclarations = { PropertyPanelCompare: ['src/sw/sw-prope
export const laterRetiredHeaderIds = [...retiredPropertyPanelIds, ...retiredUnusedDemoIds] export const laterRetiredHeaderIds = [...retiredPropertyPanelIds, ...retiredUnusedDemoIds]
// The approved additions that sit under SW/Header. // The approved additions that sit under SW/Header.
export const approvedAddedHeaderIds = approvedAddedStoryIds.filter(id => id.startsWith('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 // 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 // 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). // 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) } 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: 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: 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. // ---- Source: retained Story declarations byte-identical; product, AI template and other packages untouched.
const sourcePath = 'src/sw/sw-header.stories.tsx' const sourcePath = 'src/sw/sw-header.stories.tsx'
@@ -89,8 +89,12 @@ let reference, viewerMenu, moreMenu, refTrigger
const menu = header.getByRole('menu', { name: '추가 도구' }) const menu = header.getByRole('menu', { name: '추가 도구' })
reference = await face(menu, MORE_ITEM) 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}` }) 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') 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() await responsive.locator('[data-width="520"] .hm-sw-viewer-header').getByRole('button', { name: '추가 도구' }).click()
const more520 = responsive.getByRole('menu', { name: '추가 도구' }) 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') 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 layout = await header.evaluate(element => {
const button = element.querySelector('[data-document-open]'), wrapper = button.parentElement 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'), 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) 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(); 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)] } }) 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); 34px slot kept. // 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
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) // 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 // 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). // 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)] }) 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) => { const openState = width => header.evaluate(async (element, width) => {
element.style.width = `${width}px` element.style.width = `${width}px`
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))) await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
const button = element.querySelector('[data-document-open]'), icon = button.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect() 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)], overflow: element.scrollWidth - element.clientWidth } 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) }, width)
const sweep = [] const sweep = []
for (let width = 1440; width >= 400; width -= 20) sweep.push(await openState(width)) for (let width = 1440; width >= 400; width -= 20) sweep.push(await openState(width))
const back = [] const back = []
for (let width = 400; width <= 1440; width += 20) back.push(await openState(width)) for (let width = 400; width <= 1440; width += 20) back.push(await openState(width))
const firstIcon = sweep.find(item => !item.label) const firstIcon = sweep.find(item => !item.label)
check('1440: icon + 파일 열기; narrower: icon only (tooltip / name 파일 열기, 34px icon slot kept)', sweep[0].label && 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.
&& sweep.filter(item => !item.label).every(item => item.aria === '파일 열기' && item.title === '파일 열기' && item.icon.join() === '34,34'), { first: sweep[0], firstIcon }) 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)) 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) await page.waitForTimeout(60)
const more = header.getByRole('button', { name: '추가 도구' }) const more = header.getByRole('button', { name: '추가 도구' })
await more.click() await more.click()
@@ -223,7 +235,8 @@ let reference, viewerMenu, moreMenu, refTrigger
// ---------- Responsive ---------- // ---------- Responsive ----------
{ {
// Tall enough that the 520 row and its popups are inside the viewport (the Story page does not scroll them in). // 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') await open(page, 'sw-viewer--responsive')
const headers = page.locator('[data-width] .hm-sw-viewer-header') const headers = page.locator('[data-width] .hm-sw-viewer-header')
const report = [] 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))) 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 // 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. // 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)) 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])) && 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('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, then More for what still does not fit', report[6].more && report[6].toggles.every(toggle => !toggle.label && toggle.title), report[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])) 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: '추가 도구' }) const more = headers.nth(6).getByRole('button', { name: '추가 도구' })
await more.click() await more.click()
const moreMenu = page.getByRole('menu', { name: '추가 도구' }) 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)')] 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,]+$/, ''))) .map(item => item.getAttribute('aria-label') ?? item.textContent.trim().replace(/[\d,]+$/, '')))
const all = ['Model', 'Layout', '전체 보기', '2.5D', '어두운 배경', '레이어', '설정', '속성'] 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.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. // Icon toggle at 520 still reports pressed / commands.
const icon = headers.nth(3).getByRole('button', { name: '레이어', exact: true }) const icon = headers.nth(3).getByRole('button', { name: '레이어', exact: true })
await icon.click() await icon.click()
@@ -289,14 +312,15 @@ let reference, viewerMenu, moreMenu, refTrigger
for (const [name, dialog] of [['레이어', /레이어 \(/], ['설정', '옵션'], ['속성', '속성']]) { for (const [name, dialog] of [['레이어', /레이어 \(/], ['설정', '옵션'], ['속성', '속성']]) {
const toggle = header.getByRole('button', { name, exact: true }) const toggle = header.getByRole('button', { name, exact: true })
await toggle.hover() 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() await toggle.click()
const panel = page.getByRole('dialog', { name: dialog }) 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) }) 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() await toggle.click()
panels.at(-1).closed = (await panel.count()) === 0 && (await toggle.getAttribute('aria-pressed')) === 'false' 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.screenshot({ path: path.join(out, `viewer-narrow-${width}.png`), clip: { ...(await box(header)), height: 56 } })
await page.close() 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 // 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 // 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. // 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) const page = await newPage(1920, 900)
await open(page, 'sw-viewer--viewer-header') await open(page, 'sw-viewer--viewer-header')
const header = page.locator('.hm-sw-viewer-header').first() 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 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 anyIcon = icons.some(button => !button.querySelector('.hm-sw-viewer-header__label'))
const more = !!element.querySelector('.hm-sw-viewer-header__more') 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) }, 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 sweep = async () => {
const down = [], up = [] const down = [], up = []
for (let width = 1440; width >= 400; width -= 10) down.push(await state(width)) 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 boundaries = list => ({ icons: list.find(item => item.stage >= 1)?.width, more: list.find(item => item.stage === 2)?.width })
const long = await sweep() 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: 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: icons always before More; panel toggles turn into icons before 전체 보기', [...long.down, ...long.up].every(item => (!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))) && (!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: 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)) 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.evaluate(element => { element.style.width = '' })
await header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click() await header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click()
await header.getByRole('button', { name: 'simple.dxf' }).click() await header.getByRole('button', { name: 'simple.dxf' }).click()
const short = await sweep() const short = await sweep()
const [a, b] = [boundaries(long.down), boundaries(short.down)] 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() await page.close()
} }
+2 -1
View File
@@ -32,7 +32,8 @@
--hm-lnb-item-min-height: 48px; --hm-lnb-item-min-height: 48px;
/* 1-depth row: 28px line + 2 x 10px = 48px. */ /* 1-depth row: 28px line + 2 x 10px = 48px. */
--hm-lnb-item-padding-block: 10px; --hm-lnb-item-padding-block: 10px;
--hm-lnb-item-padding-start: 18px; /* 1-depth text start 12px (2026-11-12, was 18px), same as the Storybook Manager sidebar rows. */
--hm-lnb-item-padding-start: 12px;
--hm-lnb-item-gap: 12px; --hm-lnb-item-gap: 12px;
--hm-lnb-item-font-size: 14px; --hm-lnb-item-font-size: 14px;
--hm-lnb-item-line-height: 28px; --hm-lnb-item-line-height: 28px;
+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 type { Meta, StoryObj } from "@storybook/react-vite"
import { TunnelFacility } from "@hm-design/center-graphic"
import { import {
BridgeReferenceDemo, CompactFacilityConnection,
IntersectionReferenceDemo, FACILITY_CONFIGS,
InterchangeReferenceDemo, LocalAssetFacility,
JunctionReferenceDemo, RoadsideFacilityTag,
LinkedFacilityReferenceDemo, TunnelFacility,
RoadsideReferenceDemo, type FacilityConfig,
TunnelReferenceDemo, type RoadsideEntity,
} from "@hm-design/center-graphic/reference" } from "@hm-design/center-graphic"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { LinkedFacilityReferenceDemo } from "@hm-design/center-graphic/reference"
import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { withRepresentation } from "./center-shared" import { withRepresentation } from "./center-shared"
// Owners: ui-viz docs/visualization/facility.md and facility-assets.md (128 approved SVG references, shipped unchanged). // 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> 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. // 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 => ({ const page = (name: string, Demo: ComponentType): Story => ({
name, name,
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title={name} /> <StoryPageHeader title={name} />
<StorySection title="개요"> <div className="sb-page-indent">
{withRepresentation(() => <Demo />)} {withRepresentation(() => <Demo />)}
</StorySection> </div>
</StoryPage> </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. // `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 Tunnel: Story = { ...page("터널", TunnelSpecimens), name: "터널" }
export const Bridge: Story = { ...page("교량", BridgeReferenceDemo), name: "교량" } export const Bridge: Story = { ...page("교량", () => <FacilitySpecimens config={FACILITY_CONFIGS.bridge} compareAt="1210" />), name: "교량" }
export const Intersection: Story = { ...page("교차로", IntersectionReferenceDemo), name: "교차로" } export const Intersection: Story = { ...page("교차로", () => <FacilitySpecimens config={FACILITY_CONFIGS.intersection} />), name: "교차로" }
export const Junction: Story = { ...page("분기점", JunctionReferenceDemo), name: "분기점" } export const Junction: Story = { ...page("분기점", () => <FacilitySpecimens config={FACILITY_CONFIGS.junction} />), name: "분기점" }
export const Interchange: Story = { ...page("나들목", InterchangeReferenceDemo), name: "나들목" } export const Interchange: Story = { ...page("나들목", () => <FacilitySpecimens config={FACILITY_CONFIGS.interchange} />), name: "나들목" }
export const Roadside: Story = { ...page("휴게소 · 졸음쉼터", RoadsideReferenceDemo), name: "휴게소 · 졸음쉼터" } export const Roadside: Story = { ...page("휴게소 · 졸음쉼터", RoadsideSpecimens), name: "연계 노선" }
// Linked facilities are a standalone section heading followed by item cards. // Linked facilities are a standalone section heading followed by item cards.
// Keep the reference data/artwork and road-class grouping owned by the package. // 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 = { export const Linked: Story = {
...page("연계 시설물", LinkedFacilityReferenceDemo), ...page("연계 시설물", LinkedFacilityReferenceDemo),
name: "연계 시설물", name: "연계 시설물",
render: () => ( render: () => (
<StoryPage className="sb-center-linked-page"> <StoryPage className="sb-center-linked-page">
<StoryPageHeader title="연계 시설물" /> <StoryPageHeader title="연계 시설물" />
<div className="sb-page-indent">
{withRepresentation(() => <LinkedFacilityReferenceDemo />)} {withRepresentation(() => <LinkedFacilityReferenceDemo />)}
</div>
</StoryPage> </StoryPage>
), ),
} }
+4 -3
View File
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { CenterInformationDisplayReferenceDemo } from "@hm-design/center-graphic/reference" 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" import { withRepresentation } from "./center-shared"
// Owner: ui-viz docs/patterns/center-information-presentation.md and center-information-{box,table,emphasis}.md. // 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: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="정보" /> <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(() => ( {withRepresentation(() => (
<div className="sb-center-information"> <div className="sb-center-information">
<CenterInformationDisplayReferenceDemo /> <CenterInformationDisplayReferenceDemo />
</div> </div>
))} ))}
</StorySection> </div>
</StoryPage> </StoryPage>
), ),
} }
+5 -3
View File
@@ -10,8 +10,10 @@ export const CENTER_BOUNDARY =
export const SCALE_NOTE = export const SCALE_NOTE =
"제작 대상이 7680 × 2160이면 CENTER 그래픽을 100%, 3840 × 2160이면 75%로 표시합니다. Storybook 화면 크기는 배율을 정하지 않으며, 다른 제작 해상도의 배율은 정해지지 않았습니다. 제작 대상은 주소에 centerResolution=3840x2160 을 붙여 바꿉니다." "제작 대상이 7680 × 2160이면 CENTER 그래픽을 100%, 3840 × 2160이면 75%로 표시합니다. Storybook 화면 크기는 배율을 정하지 않으며, 다른 제작 해상도의 배율은 정해지지 않았습니다. 제작 대상은 주소에 centerResolution=3840x2160 을 붙여 바꿉니다."
export const CenterFrame = ({ children }: { children: ReactNode }) => ( // `plain` drops the review card frames on 노선도 pages only. OrgChart previews reuse `.sb-center` and the card
<div className="sb-center" lang="ko"> // 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} {children}
</div> </div>
) )
@@ -23,7 +25,7 @@ export const centerTargetResolution = (): CenterTargetResolution => {
} }
export const withRepresentation = (Story: () => ReactNode) => ( export const withRepresentation = (Story: () => ReactNode) => (
<CenterFrame> <CenterFrame plain>
<CenterRepresentation targetResolution={centerTargetResolution()}> <CenterRepresentation targetResolution={centerTargetResolution()}>
<Story /> <Story />
</CenterRepresentation> </CenterRepresentation>
+4 -3
View File
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { RouteLine } from "@hm-design/center-graphic" import { RouteLine } from "@hm-design/center-graphic"
import { CenterSpatialInformationReferenceDemo } from "@hm-design/center-graphic/reference" 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" import { withRepresentation } from "./center-shared"
// Owners: ui-viz docs/visualization/route.md, spatial-map.md, spatial-section.md, composition.md. // Owners: ui-viz docs/visualization/route.md, spatial-map.md, spatial-section.md, composition.md.
@@ -21,9 +21,10 @@ export const Spatial: Story = {
render: () => ( render: () => (
<StoryPage className="sb-center-spatial-page"> <StoryPage className="sb-center-spatial-page">
<StoryPageHeader title="노선·공간" /> <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 />)} {withRepresentation(() => <CenterSpatialInformationReferenceDemo />)}
</StorySection> </div>
</StoryPage> </StoryPage>
), ),
} }
+370 -16
View File
@@ -166,22 +166,8 @@
gap: var(--sb-center-section-gap); gap: var(--sb-center-section-gap);
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.sb-center .linked-facility__card { /* 2026-11-13: the former narrow-card label stacking (`sb-linked-card` container) is replaced by the 연계 시설물
container: sb-linked-card / inline-size; card layout at the end of this file (POSITION | ACTUAL columns, `sb-linked-list` container). */
}
/* 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;
}
}
.sb-center .sb-center-status { min-width: 720px; } .sb-center .sb-center-status { min-width: 720px; }
/* Spatial cards reuse the common frame above. Contrast belongs to the inner /* Spatial cards reuse the common frame above. Contrast belongs to the inner
@@ -249,3 +235,371 @@
grid-template-columns: minmax(0, 1fr); 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 type { Meta, StoryObj } from "@storybook/react-vite"
import { CenterTitle, CENTER_TITLE_COMPOSITIONS, type CenterTitleComposition } from "@hm-design/center-graphic" import { CenterTitle, CENTER_TITLE_COMPOSITIONS, type CenterTitleComposition } from "@hm-design/center-graphic"
import { CenterTitleReferenceDemo } from "@hm-design/center-graphic/reference" import type { ComponentProps } from "react"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { withRepresentation } from "./center-shared" 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. // 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 = { const meta = {
@@ -20,11 +66,13 @@ export const Compositions: Story = {
name: "사업명", name: "사업명",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( 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="사업명" /> <StoryPageHeader title="사업명" />
<StorySection title="개요"> <div className="sb-page-indent">
{withRepresentation(() => <CenterTitleReferenceDemo />)} {withRepresentation(() => <TitleSpecimens />)}
</StorySection> </div>
</StoryPage> </StoryPage>
), ),
} }
+6 -6
View File
@@ -22,22 +22,22 @@ export const Area: Story = {
<ChartSampleNotice /> <ChartSampleNotice />
{/* Same grid and order as Line Chart, then a Stacked | Expanded row. */} {/* Same grid and order as Line Chart, then a Stacked | Expanded row. */}
<StorySectionGrid className={chartGrid.pairs}> <StorySectionGrid className={chartGrid.pairs}>
<ChartSpecimen title="Curve" description="monotone"> <ChartSpecimen title="Curve">
<HMAreaChart {...sampleLineCompare} curve="monotone" label="월별 접수·완료 곡선형 영역 차트(예시 데이터)" /> <HMAreaChart {...sampleLineCompare} curve="monotone" label="월별 접수·완료 곡선형 영역 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Curve · Dot + Label" description="monotone · Dot과 값 Label"> <ChartSpecimen title="Curve · Dot + Label">
<HMAreaChart {...sampleLineCompare} curve="monotone" pointLabels label="월별 접수·완료 곡선형 Dot·값 Label 영역 차트(예시 데이터)" /> <HMAreaChart {...sampleLineCompare} curve="monotone" pointLabels label="월별 접수·완료 곡선형 Dot·값 Label 영역 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Straight" description="linear"> <ChartSpecimen title="Straight">
<HMAreaChart {...sampleLineCompare} curve="linear" label="월별 접수·완료 직선형 영역 차트(예시 데이터)" /> <HMAreaChart {...sampleLineCompare} curve="linear" label="월별 접수·완료 직선형 영역 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Straight · Color Dot + Label" description="linear · Dot 개별 색상 + 값 Label"> <ChartSpecimen title="Straight · Color Dot + Label">
<HMAreaChart {...sampleLinePointColors} curve="linear" pointLabels label="월별 접수·완료 직선형 포인트별 색상 영역 차트(예시 데이터)" /> <HMAreaChart {...sampleLinePointColors} curve="linear" pointLabels label="월별 접수·완료 직선형 포인트별 색상 영역 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Step" description="step"> <ChartSpecimen title="Step">
<HMAreaChart {...sampleLineCompare} curve="step" label="월별 접수·완료 계단형 영역 차트(예시 데이터)" /> <HMAreaChart {...sampleLineCompare} curve="step" label="월별 접수·완료 계단형 영역 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Step · Dot + Label" description="step · Dot과 값 Label"> <ChartSpecimen title="Step · Dot + Label">
<HMAreaChart {...sampleLineCompare} curve="step" pointLabels label="월별 접수·완료 계단형 Dot·값 Label 영역 차트(예시 데이터)" /> <HMAreaChart {...sampleLineCompare} curve="step" pointLabels label="월별 접수·완료 계단형 Dot·값 Label 영역 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
</StorySectionGrid> </StorySectionGrid>
+1 -1
View File
@@ -26,7 +26,7 @@ export const Bar: Story = {
<ChartSpecimen title="Grouped" description="월별 접수·완료 현황 · 상반기 월별 건수"> <ChartSpecimen title="Grouped" description="월별 접수·완료 현황 · 상반기 월별 건수">
<HMBarChart {...sampleBar} label="월별 접수·완료 건수 막대 차트(예시 데이터)" /> <HMBarChart {...sampleBar} label="월별 접수·완료 건수 막대 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Horizontal" description="항목별 처리 건수 · 가로 막대"> <ChartSpecimen title="Horizontal" description="항목별 처리 건수">
<HMHorizontalBarChart {...sampleHorizontalBar} label="항목별 처리 건수 가로 막대 차트(예시 데이터)" /> <HMHorizontalBarChart {...sampleHorizontalBar} label="항목별 처리 건수 가로 막대 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Stacked" description="월별 접수·완료 현황(누적)"> <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). // 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 // 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 // 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 // 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). // only sets the chart's own surface token (chartPageClass).
+4 -4
View File
@@ -56,17 +56,17 @@ export const Donut: Story = {
<ChartSampleNotice /> <ChartSampleNotice />
{/* Two per row from 1024px, three from 1200px, one below; source order is the mobile order. */} {/* Two per row from 1024px, three from 1200px, one below; source order is the mobile order. */}
<StorySectionGrid className={chartGrid.compact}> <StorySectionGrid className={chartGrid.compact}>
<ChartSpecimen title="Basic" description="기본" toolbar="중앙 표시 없음"> <ChartSpecimen title="Basic" toolbar="중앙 표시 없음">
<HMDonutChart data={sampleDonutStates.data} label="업무 상태 도넛 차트, 가운데 값 없음(예시 데이터)" /> <HMDonutChart data={sampleDonutStates.data} label="업무 상태 도넛 차트, 가운데 값 없음(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Basic · Center Text" description="전체 합계" toolbar="중앙 표시: 전체 합계"> <ChartSpecimen title="Basic · Center Text" toolbar="중앙 표시: 전체 합계">
<HMDonutChart data={sampleDonutStates.data} totalLabel={sampleDonutStates.totalLabel} label="업무 상태 도넛 차트, 가운데 전체 합계(예시 데이터)" /> <HMDonutChart data={sampleDonutStates.data} totalLabel={sampleDonutStates.totalLabel} label="업무 상태 도넛 차트, 가운데 전체 합계(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Active" description="선택된 항목" toolbar={`선택 항목: ${selected?.label ?? ""}`}> <ChartSpecimen title="Active" toolbar={`선택 항목: ${selected?.label ?? ""}`}>
<HMDonutChart data={sampleDonutStates.data} activeId={sampleDonutStates.activeId} label="업무 상태 도넛 차트, 선택된 항목 강조(예시 데이터)" /> <HMDonutChart data={sampleDonutStates.data} activeId={sampleDonutStates.activeId} label="업무 상태 도넛 차트, 선택된 항목 강조(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<DonutInteractive /> <DonutInteractive />
<ChartSpecimen title="Solid" description="입체형" toolbar={null}> <ChartSpecimen title="Solid" toolbar={null}>
<HMSolidDonutChart data={sampleDonutStates.data} totalLabel={sampleDonutStates.totalLabel} label="업무 상태 입체형 도넛 차트, 가운데 전체 합계(예시 데이터)" /> <HMSolidDonutChart data={sampleDonutStates.data} totalLabel={sampleDonutStates.totalLabel} label="업무 상태 입체형 도넛 차트, 가운데 전체 합계(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
</StorySectionGrid> </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 {/* 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. */} first row and their Dot + Label versions below. */}
<StorySectionGrid className={chartGrid.pairs}> <StorySectionGrid className={chartGrid.pairs}>
<ChartSpecimen title="Curve" description="monotone · Dot 없음"> <ChartSpecimen title="Curve">
<HMLineChart {...sampleLineCompare} curve="monotone" dots={false} label="월별 접수·완료 곡선형 선 차트(예시 데이터)" /> <HMLineChart {...sampleLineCompare} curve="monotone" dots={false} label="월별 접수·완료 곡선형 선 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Curve · Dot + Label" description="monotone · Dot과 값 Label"> <ChartSpecimen title="Curve · Dot + Label">
<HMLineChart {...sampleLineCompare} curve="monotone" pointLabels label="월별 접수·완료 곡선형 Dot·값 라벨 선 차트(예시 데이터)" /> <HMLineChart {...sampleLineCompare} curve="monotone" pointLabels label="월별 접수·완료 곡선형 Dot·값 라벨 선 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Straight" description="linear · Dot 없음"> <ChartSpecimen title="Straight">
<HMLineChart {...sampleLineCompare} curve="linear" dots={false} label="월별 접수·완료 직선형 선 차트(예시 데이터)" /> <HMLineChart {...sampleLineCompare} curve="linear" dots={false} label="월별 접수·완료 직선형 선 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Straight · Color Dot + Label" description="linear · Dot 개별 색상 + 값 Label"> <ChartSpecimen title="Straight · Color Dot + Label">
<HMLineChart {...sampleLinePointColors} curve="linear" pointLabels label="월별 접수·완료 직선형 포인트별 색상 선 차트(예시 데이터)" /> <HMLineChart {...sampleLinePointColors} curve="linear" pointLabels label="월별 접수·완료 직선형 포인트별 색상 선 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Step" description="step · Dot 없음"> <ChartSpecimen title="Step">
<HMLineChart {...sampleLineCompare} curve="step" dots={false} label="월별 접수·완료 계단형 선 차트(예시 데이터)" /> <HMLineChart {...sampleLineCompare} curve="step" dots={false} label="월별 접수·완료 계단형 선 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Step · Dot + Label" description="step · Dot과 값 Label"> <ChartSpecimen title="Step · Dot + Label">
<HMLineChart {...sampleLineCompare} curve="step" pointLabels label="월별 접수·완료 계단형 Dot·값 라벨 선 차트(예시 데이터)" /> <HMLineChart {...sampleLineCompare} curve="step" pointLabels label="월별 접수·완료 계단형 Dot·값 라벨 선 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
</StorySectionGrid> </StorySectionGrid>
+4 -4
View File
@@ -21,16 +21,16 @@ export const Radar: Story = {
<ChartSampleNotice /> <ChartSampleNotice />
{/* Two per row from 1024px, all four in one row from 1600px, one below. */} {/* Two per row from 1024px, all four in one row from 1600px, one below. */}
<StorySectionGrid className={chartGrid.quad}> <StorySectionGrid className={chartGrid.quad}>
<ChartSpecimen title="Basic" description="영역"> <ChartSpecimen title="Basic">
<HMRadarChart {...sampleRadar} label="단계별 처리 수준 방사형 차트(예시 데이터)" /> <HMRadarChart {...sampleRadar} label="단계별 처리 수준 방사형 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Dot" description="축별 Dot"> <ChartSpecimen title="Dot">
<HMRadarChart {...sampleRadar} dots label="단계별 처리 수준 방사형 차트, Dot(예시 데이터)" /> <HMRadarChart {...sampleRadar} dots label="단계별 처리 수준 방사형 차트, Dot(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Lines Only" description="선만"> <ChartSpecimen title="Lines Only">
<HMRadarChart {...sampleRadar} filled={false} label="단계별 처리 수준 방사형 차트, 선만(예시 데이터)" /> <HMRadarChart {...sampleRadar} filled={false} label="단계별 처리 수준 방사형 차트, 선만(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Custom Label" description="축 Label"> <ChartSpecimen title="Custom Label">
<HMRadarChart {...sampleRadar} customLabels label="단계별 처리 수준 방사형 차트, 축 Label(예시 데이터)" /> <HMRadarChart {...sampleRadar} customLabels label="단계별 처리 수준 방사형 차트, 축 Label(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
</StorySectionGrid> </StorySectionGrid>
+6 -6
View File
@@ -21,22 +21,22 @@ export const Radial: Story = {
<ChartSampleNotice /> <ChartSampleNotice />
{/* Two per row from 1024px, three from 1200px, one below; source order is the mobile order. */} {/* Two per row from 1024px, three from 1200px, one below; source order is the mobile order. */}
<StorySectionGrid className={chartGrid.compact}> <StorySectionGrid className={chartGrid.compact}>
<ChartSpecimen title="Basic" description="radial-simple · 항목별 막대"> <ChartSpecimen title="Basic" description="항목별 막대">
<HMRadialBarChart {...sampleRadial} label="업무 상태 방사형 막대 차트(예시 데이터)" /> <HMRadialBarChart {...sampleRadial} label="업무 상태 방사형 막대 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Label" description="radial-label · 막대 안 항목명"> <ChartSpecimen title="Label" description="막대 안 항목명">
<HMRadialBarChart {...sampleRadial} labels startAngle={-90} endAngle={380} label="업무 상태 방사형 막대 차트, 항목명 Label(예시 데이터)" /> <HMRadialBarChart {...sampleRadial} labels startAngle={-90} endAngle={380} label="업무 상태 방사형 막대 차트, 항목명 Label(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Grid" description="radial-grid · 원형 Grid"> <ChartSpecimen title="Grid" description="원형 Grid">
<HMRadialBarChart {...sampleRadial} grid label="업무 상태 방사형 막대 차트, Grid(예시 데이터)" /> <HMRadialBarChart {...sampleRadial} grid label="업무 상태 방사형 막대 차트, Grid(예시 데이터)" />
</ChartSpecimen> </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(예시 데이터)" /> <HMRadialMeterChart {...sampleRadialText} startAngle={0} endAngle={250} innerRadius={75} outerRadius={90} trackRadii={[88.5, 76.5]} cornerRadius={10} label="진행 중 건수 방사형 차트, 중앙 Text(예시 데이터)" />
</ChartSpecimen> </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(예시 데이터)" /> <HMRadialMeterChart {...sampleRadialShape} startAngle={0} endAngle={100} innerRadius={50} outerRadius={95} trackRadii={[90.5, 54.5]} label="진행 중 건수 방사형 차트, Shape(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
<ChartSpecimen title="Stacked" description="radial-stacked · 반원 누적"> <ChartSpecimen title="Stacked" description="반원 누적">
<HMRadialStackedChart {...sampleRadialStacked} label="업무 상태 방사형 Stacked 차트(예시 데이터)" /> <HMRadialStackedChart {...sampleRadialStacked} label="업무 상태 방사형 Stacked 차트(예시 데이터)" />
</ChartSpecimen> </ChartSpecimen>
</StorySectionGrid> </StorySectionGrid>
+8
View File
@@ -0,0 +1,8 @@
.sb-page.sb-accordion-story {
--sb-page-section-gap: 24px;
}
.sb-accordion-story .sb-page-section-body > [data-sb-board] {
/* Keep empty tracks aligned and cap the shared board at three columns. */
grid-template-columns: repeat(auto-fill, minmax(min(100%, max(320px, (100% - 48px) / 3)), 1fr));
}
+37 -30
View File
@@ -1,8 +1,16 @@
import type { ReactNode } from "react" import type { ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@hm-design/ui" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board" import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import {
ClosedSpecimen as CollapsibleClosed,
ControlledSpecimen as CollapsibleControlled,
DisabledSpecimen as CollapsibleDisabled,
LongSpecimen as CollapsibleLong,
OpenSpecimen as CollapsibleOpen,
} from "./collapsible-examples"
import "./accordion-story.css"
// The representative "Accordion" Story puts each Radix mode on one page with real, clickable items. The same sections also exist // The representative "Accordion" Story puts each Radix mode on one page with real, clickable items. The same sections also exist
// as Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts). Modes use the Radix API // as Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts). Modes use the Radix API
@@ -102,45 +110,44 @@ function LongSpecimen() {
) )
} }
const sections = [
{ id: "single", label: "Single", node: <SingleSpecimen /> },
{ id: "collapsible-single", label: "Collapsible Single", node: <CollapsibleSingleSpecimen /> },
{ id: "multiple", label: "Multiple", node: <MultipleSpecimen /> },
{ id: "disabled", label: "Disabled Item", node: <DisabledSpecimen /> },
{ id: "long", label: "Long Content", node: <LongSpecimen /> },
]
// No `component` here: Accordion props are a union (single | multiple), so Stories without args would not type-check. // No `component` here: Accordion props are a union (single | multiple), so Stories without args would not type-check.
const meta = { title: "Components/Disclosure/Accordion", parameters: { layout: "padded" } } satisfies Meta const meta = { title: "Components/Disclosure/Accordion", parameters: { layout: "padded" } } satisfies Meta
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), one column // The combined disclosure page uses the same specimens as the separate verification Stories.
// below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so the title, divider and
// specimen start line up, unused columns stay empty and `items-start` lets an opened item grow without touching its neighbors.
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
return (
<div className={`grid grid-cols-1 items-start gap-6 ${cols === 3 ? "min-[1000px]:grid-cols-3" : "min-[1000px]:grid-cols-2"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
const board = (cols: 2 | 3, ...ids: string[]) => (
<StateGrid cols={cols} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative Story: the common Story page; the sections group the same specimens.
export const All: Story = { export const All: Story = {
name: "Accordion", name: "Accordion",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage className="sb-accordion-story">
<StoryPageHeader title="Accordion" /> <StoryPageHeader title="Accordion" />
<StorySection title="Variants">{board(3, "single", "collapsible-single", "multiple")}</StorySection> <StorySection title="Variants">
<StorySection title="States">{board(3, "disabled", "long")}</StorySection> <StateBoard states={[
{ id: "single", label: "Single", children: <SingleSpecimen /> },
{ id: "collapsible-single", label: "Collapsible Single", children: <CollapsibleSingleSpecimen /> },
{ id: "multiple", label: "Multiple", children: <MultipleSpecimen /> },
]} />
</StorySection>
<StorySection title="States">
<StateBoard states={[
{ id: "disabled", label: "Disabled Item", children: <DisabledSpecimen /> },
]} />
</StorySection>
<StorySection title="Collapsible · States">
<StateBoard states={[
{ id: "collapsible-closed", label: "Closed", children: <CollapsibleClosed /> },
{ id: "collapsible-open", label: "Open", children: <CollapsibleOpen /> },
{ id: "collapsible-disabled", label: "Disabled", children: <CollapsibleDisabled /> },
]} />
</StorySection>
<StorySection title="Content · Usage">
<StateBoard states={[
{ id: "long", label: "Accordion · Long Content", children: <LongSpecimen /> },
{ id: "collapsible-long", label: "Collapsible · Long Content", children: <CollapsibleLong /> },
{ id: "collapsible-controlled", label: "Button trigger", children: <CollapsibleControlled /> },
]} />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+64
View File
@@ -0,0 +1,64 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
} from "@hm-design/ui"
// Shared Alert Dialog specimens (Storybook only). Used by the Alert Dialog Stories (verification) and by the Dialog representative
// page, which shows general and confirmation / warning dialogs under one sidebar item ("대화상자").
// Alert Dialog is a modal Radix overlay (Portal) for a decision that must be confirmed or cancelled: no Close (X), no outside
// dismiss. Action / Cancel are the HM Button: both confirmation and deletion use a Solid pair
// (deletion pairs Cancel with a destructive Action).
export function AlertDialogConfirmation({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<AlertDialog defaultOpen={defaultOpen}>
<AlertDialogTrigger asChild>
<Button variant="primary" appearance="outline">
로그아웃
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>로그아웃하시겠습니까?</AlertDialogTitle>
<AlertDialogDescription>저장하지 않은 변경 사항은 사라집니다.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel appearance="solid">취소</AlertDialogCancel>
<AlertDialogAction variant="primary" appearance="solid">로그아웃</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
export function AlertDialogDestructive({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<AlertDialog defaultOpen={defaultOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" appearance="outline">
삭제
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>프로젝트를 삭제하시겠습니까?</AlertDialogTitle>
<AlertDialogDescription>삭제하면 되돌릴 수 없습니다. 프로젝트의 모든 데이터가 함께 삭제됩니다.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel appearance="solid">취소</AlertDialogCancel>
<AlertDialogAction variant="destructive" appearance="solid">
삭제
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
+8 -63
View File
@@ -1,76 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { import { AlertDialog } from "@hm-design/ui"
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
} from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { AlertDialogConfirmation as Confirmation, AlertDialogDestructive as Destructive } from "./alert-dialog-examples"
// Alert Dialog is a modal Radix overlay (Portal) for a decision that must be confirmed or cancelled: no Close (X), no outside // Alert Dialog is a modal Radix overlay (Portal) for a decision that must be confirmed or cancelled: no Close (X), no outside
// dismiss. The representative Story opens the real dialogs; the same cases start open in the verification Stories. // dismiss. The specimens live in ./alert-dialog-examples and are also shown on the Dialog representative page, which is the
// Action / Cancel are the HM Button: ordinary confirmation uses a Solid pair; deletion uses an Outline pair. // single sidebar item for both ("대화상자"). Every Story here keeps its ID and direct URL but is hidden from the sidebar.
function Confirmation({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<AlertDialog defaultOpen={defaultOpen}>
<AlertDialogTrigger asChild>
<Button variant="primary" appearance="outline">
로그아웃
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>로그아웃하시겠습니까?</AlertDialogTitle>
<AlertDialogDescription>저장하지 않은 변경 사항은 사라집니다.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel appearance="solid">취소</AlertDialogCancel>
<AlertDialogAction variant="primary" appearance="solid">로그아웃</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
function Destructive({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<AlertDialog defaultOpen={defaultOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" appearance="outline">
삭제
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>프로젝트를 삭제하시겠습니까?</AlertDialogTitle>
<AlertDialogDescription>삭제하면 되돌릴 수 없습니다. 프로젝트의 모든 데이터가 함께 삭제됩니다.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel appearance="solid">취소</AlertDialogCancel>
<AlertDialogAction variant="destructive" appearance="solid">
삭제
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
const meta = { title: "Components/Overlay/Alert Dialog", component: AlertDialog, parameters: { layout: "centered" } } satisfies Meta<typeof AlertDialog> const meta = { title: "Components/Overlay/Alert Dialog", component: AlertDialog, parameters: { layout: "centered" } } satisfies Meta<typeof AlertDialog>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative Story: the common Story page; each trigger opens the real dialog.
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider // (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its // has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them. // own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) { function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
@@ -83,8 +26,10 @@ function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?:
) )
} }
// Representative Story: Overview and Variants are merged into one Variants section. // Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Dialog page
// shows the same specimens.
export const All: Story = { export const All: Story = {
tags: ["verification"],
name: "Alert Dialog", name: "Alert Dialog",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
+1 -1
View File
@@ -58,7 +58,7 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider // (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. // has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
// The Card example spans two columns (about its previous width); the ratio boxes fill their column by design. // The Card example spans two columns (about its previous width); the ratio boxes fill their column by design.
const specimenColumns = { const specimenColumns = {
+1 -1
View File
@@ -77,7 +77,7 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (240px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider // (240px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. // has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
const specimenColumns = { const specimenColumns = {
2: "min-[1000px]:grid-cols-2", 2: "min-[1000px]:grid-cols-2",
+1 -1
View File
@@ -24,7 +24,7 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (280px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider // (280px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. // has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
const specimenColumns = { const specimenColumns = {
2: "min-[1000px]:grid-cols-2", 2: "min-[1000px]:grid-cols-2",
+8 -8
View File
@@ -99,7 +99,7 @@ function CustomLink() {
<BreadcrumbList> <BreadcrumbList>
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink asChild> <BreadcrumbLink asChild>
<a href="#router-home">홈 (custom link)</a> <a href="#router-home">홈</a>
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator>/</BreadcrumbSeparator> <BreadcrumbSeparator>/</BreadcrumbSeparator>
@@ -115,15 +115,15 @@ const sections = [
{ id: "basic", label: "Basic", node: <Basic /> }, { id: "basic", label: "Basic", node: <Basic /> },
{ id: "ellipsis", label: "Ellipsis", node: <Ellipsis /> }, { id: "ellipsis", label: "Ellipsis", node: <Ellipsis /> },
{ id: "long", label: "Long path", node: <Long /> }, { id: "long", label: "Long path", node: <Long /> },
{ id: "custom", label: "Custom link · separator", node: <CustomLink /> }, { id: "custom", label: "Custom", node: <CustomLink /> },
] ]
const meta = { title: "Components/Navigation/Breadcrumb", component: Breadcrumb, parameters: { layout: "padded" } } satisfies Meta<typeof Breadcrumb> const meta = { title: "Components/Navigation/Breadcrumb", component: Breadcrumb, parameters: { layout: "padded" } } satisfies Meta<typeof Breadcrumb>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): Variants use 3 equal columns on PC (iframe width 1000px and up), // Representative-page layout (Storybook presentation only): Variants use 3 equal columns on PC (iframe width 1000px and up)
// fewer below that (240px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so // and 1 column below that (2026-11-13, same 1 → 3 pattern as Accordion / Collapsible, so no 2 + 1 row). Each specimen sits in its own single-column StateBoard (min larger than any column), so
// StateBoard's auto-fill grid cannot leave an empty or stretched track. // StateBoard's auto-fill grid cannot leave an empty or stretched track.
const specimen = (id: string, children?: ReactNode): BoardState => { const specimen = (id: string, children?: ReactNode): BoardState => {
const s = sections.find((section) => section.id === id)! const s = sections.find((section) => section.id === id)!
@@ -131,8 +131,8 @@ const specimen = (id: string, children?: ReactNode): BoardState => {
} }
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// Overview is merged into Variants (Basic is its first item). The Long path example gets up to 900px so it fits on one line on // Overview is merged into Variants (Basic is its first item). Usage (Long path) takes the full section width (2026-11-13, the
// PC; BreadcrumbList is `flex-wrap`, so it wraps naturally (never clipped or forced nowrap) when the space is narrower. // former 900px cap is removed); BreadcrumbList is `flex-wrap`, so it wraps naturally (never clipped or forced nowrap) when narrower.
export const All: Story = { export const All: Story = {
name: "Breadcrumb", name: "Breadcrumb",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
@@ -140,14 +140,14 @@ export const All: Story = {
<StoryPage> <StoryPage>
<StoryPageHeader title="Breadcrumb" /> <StoryPageHeader title="Breadcrumb" />
<StorySection title="Variants"> <StorySection title="Variants">
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3"> <div className="grid grid-cols-1 items-start gap-6 min-[1000px]:grid-cols-3">
{["basic", "ellipsis", "custom"].map((id) => ( {["basic", "ellipsis", "custom"].map((id) => (
<StateBoard key={id} min={10000} states={[specimen(id)]} /> <StateBoard key={id} min={10000} states={[specimen(id)]} />
))} ))}
</div> </div>
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<StateBoard min={10000} states={[specimen("long", <div className="w-full max-w-[900px]">{sections.find((s) => s.id === "long")!.node}</div>)]} /> <StateBoard min={10000} states={[specimen("long", <div className="w-full">{sections.find((s) => s.id === "long")!.node}</div>)]} />
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+116 -71
View File
@@ -1,8 +1,9 @@
import { useState, type ReactNode } from "react" import { createContext, useCallback, useContext, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"
import { DownloadIcon } from "lucide-react" import { DownloadIcon } from "lucide-react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button, type ButtonAppearance } from "@hm-design/ui" import { Button, type ButtonAppearance } from "@hm-design/ui"
import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
import { ToggleSections } from "./toggle-examples"
// Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx): // Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx):
// one representative "Button" Story shows kinds, sizes and states on a single screen; the same sections also exist // one representative "Button" Story shows kinds, sizes and states on a single screen; the same sections also exist
@@ -231,52 +232,96 @@ function DestructiveBoard() {
// Group label: the StateBoard H3 style (14/20/500, secondary). `data-sb-board` gives the block the common one-step section indent. // Group label: the StateBoard H3 style (14/20/500, secondary). `data-sb-board` gives the block the common one-step section indent.
const groupLabel = "m-0 text-sm font-medium text-muted-foreground" const groupLabel = "m-0 text-sm font-medium text-muted-foreground"
// Sizes / Variants (top, side by side): one row per group, label on a fixed 128px left column so every group title and first // 2026-11-13 (user request): one column per treatment, Sizes and States inside it. Each block is its H3 label (StateBoard H3
// button line up. At 640px and below the label moves above its buttons. Buttons keep the page-wide 8px gap, across and between // style), 8px, then the specimens; Sizes and States are 24px apart. The blocks stay on the section edge (no data-sb-board
// rows (same as the States matrices), and wrap only when too narrow. // indent) so the 420px Standard Solid matrix fits an equal third at the 1400px breakpoint without an inner scroll.
function GroupRows({ groups }: { groups: { id: string; label: string; children: ReactNode }[] }) { function GroupBlock({ id, label, children }: { id: string; label: string; children: ReactNode }) {
return ( return (
<div data-sb-board="" className="grid gap-2 max-sm:gap-5"> <section data-overview-state={id} aria-label={label} className="grid min-w-0 content-start gap-2">
{groups.map(({ id, label, children }) => (
<section key={id} data-overview-state={id} aria-label={label} className="flex min-w-0 items-center gap-4 max-sm:flex-col max-sm:items-start max-sm:gap-2">
<h3 className={`${groupLabel} w-32 shrink-0 max-sm:w-auto`}>{label}</h3>
<div className="flex min-w-0 flex-wrap items-center gap-2">{children}</div>
</section>
))}
</div>
)
}
// States (bottom): Brand | Standard Solid | Standard Outline columns, title on top, all tops aligned. The columns use the
// whole width, shared in proportion to their matrices (about 301 / 420 / 338px, never narrower than the matrix); each matrix
// keeps its 8px button gap and sits at the start of its column. 3 columns from a 1250px preview (the three need about 1106px
// of content plus the page margins), 2 columns (Brand + Solid, then Outline) from 890px, 1 column below; a 1-column matrix
// wider than the screen scrolls in its own box only (the page never scrolls sideways).
function GroupColumns({ groups }: { groups: { id: string; label: string; children: ReactNode }[] }) {
return (
<div
data-sb-board=""
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 min-[890px]:grid-cols-[minmax(max-content,301fr)_minmax(max-content,420fr)] min-[1250px]:grid-cols-[minmax(max-content,301fr)_minmax(max-content,420fr)_minmax(max-content,338fr)]"
>
{groups.map(({ id, label, children }) => (
<section key={id} data-overview-state={id} aria-label={label} className="grid min-w-0 content-start gap-2">
<h3 className={groupLabel}>{label}</h3> <h3 className={groupLabel}>{label}</h3>
{children} {children}
</section> </section>
))}
</div>
) )
} }
// States: one matrix per treatment, rows = Default / Hover / Pressed / Disabled (standardStates, the same 4 rows for Brand; // States: one matrix per treatment, rows = Default / Hover / Pressed / Disabled (standardStates, the same 4 rows for Brand;
// the live Interact button stays on the verification "Brand States"), columns = that treatment's colors in the Variants order. // the live Interact button stays on the verification "Brand States"), columns = that treatment's colors in the roles order.
// Columns follow their buttons (auto table layout). 4px cell padding gives the same 8px gap as Sizes / Variants between // Columns follow their buttons (auto table layout). 4px cell padding gives the same 8px gap as Sizes between buttons, rows,
// buttons, rows, the state label and the color header; the outer edge keeps none (pl-0 / last:pr-0) so the three matrices // the state label and the color header; the outer edge keeps none (pl-0 / last:pr-0). Buttons sit centered under their color
// fit one row at 1440px. Buttons sit centered under their color and are never resized. // and are never resized. Matrix widths: Brand 301px, Standard Solid 420px, Standard Outline 338px.
// 2026-11-13 (user request, no inner scroll on narrow screens): when a matrix is wider than its column (the 420px Standard Solid
// matrix in a 358px column at 390px), every treatment switches together to the stacked layout: one row per state, its label
// above the buttons, which wrap with the same 8px gap. Each matrix measures its own table (the auto-layout width it needs) and
// reports whether it fits; MatrixFitProvider stacks all of them while any does not, so the three treatments keep one layout.
// Storybook presentation only: the buttons, their props and states are unchanged.
// Stacked layout state names (2026-11-13, user request): one step below the H3 "States" label (14/20/500) so the two levels
// read apart — 12/16/400, same secondary color. Only these names change; the common H2 / H3 styles are untouched.
const stackedStateLabel = "text-xs font-normal text-muted-foreground"
const MatrixFit = createContext<{ stacked: boolean; report: (id: string, overflow: boolean) => void }>({ stacked: false, report: () => {} })
function MatrixFitProvider({ children }: { children: ReactNode }) {
const [overflowing, setOverflowing] = useState<ReadonlySet<string>>(() => new Set())
const report = useCallback((id: string, overflow: boolean) => {
setOverflowing((current) => {
if (current.has(id) === overflow) return current
const next = new Set(current)
if (overflow) next.add(id)
else next.delete(id)
return next
})
}, [])
const value = useMemo(() => ({ stacked: overflowing.size > 0, report }), [overflowing, report])
return <MatrixFit.Provider value={value}>{children}</MatrixFit.Provider>
}
function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; states: readonly (readonly [string, Record<string, unknown>])[] }) { function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; states: readonly (readonly [string, Record<string, unknown>])[] }) {
const colors = rolesFor(appearance) const colors = rolesFor(appearance)
const { stacked, report } = useContext(MatrixFit)
const frame = useRef<HTMLDivElement>(null)
// The table's own width, kept while the stacked layout is shown (no table to measure then).
const tableWidth = useRef(0)
const labelId = useId()
useLayoutEffect(() => {
const element = frame.current
if (!element) return
const check = () => {
const table = element.querySelector("table")
if (table) tableWidth.current = table.offsetWidth
report(appearance, tableWidth.current > element.clientWidth)
}
const observer = new ResizeObserver(check)
observer.observe(element)
check()
// The labels may get wider once the web font has loaded.
void document.fonts?.ready.then(check)
return () => {
observer.disconnect()
report(appearance, false)
}
}, [appearance, report])
if (stacked) {
return ( return (
<div className="min-w-0 max-w-full overflow-x-auto"> <div ref={frame} data-state-matrix="stacked" className="grid min-w-0 max-w-full gap-3">
{states.map(([state, props], index) => (
<div key={state} role="group" aria-labelledby={`${labelId}-${index}`} className="grid gap-1">
<span id={`${labelId}-${index}`} className={stackedStateLabel}>
{state}
</span>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{colors.map(([variant, color]) => (
<Button key={variant} variant={variant} appearance={appearance} {...props}>
{color}
</Button>
))}
</div>
</div>
))}
</div>
)
}
return (
<div ref={frame} data-state-matrix="table" className="min-w-0 max-w-full overflow-x-auto">
<table className="border-collapse"> <table className="border-collapse">
<thead> <thead>
<tr> <tr>
@@ -311,8 +356,16 @@ function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; sta
) )
} }
// Representative Story: the common Story page (Sizes → Variants → States). // The Toggle Button section of the representative page, in the same stacked / matrix form as the state matrices.
function ToggleButtonSection() {
const { stacked } = useContext(MatrixFit)
return <ToggleSections titlePrefix="Toggle Button" idPrefix="toggle-" flat stacked={stacked} />
}
// Representative Story: the common Story page, one section per treatment (Brand | Standard Solid | Standard Outline).
// The former Overview button was the default Brand Medium button already shown under Sizes, so Overview is merged into Sizes. // The former Overview button was the default Brand Medium button already shown under Sizes, so Overview is merged into Sizes.
// 2026-11-13 (user request): the Variants section and the page-level Sizes / States titles are removed; each treatment section
// (H2, common title → content 12px) holds its own Sizes and States (H3). The verification "Roles" Story keeps the role rows.
// Named after the component, as in ui_viz. It is the only Button entry in the default sidebar, so Storybook shows Button as a single 1-depth item. // Named after the component, as in ui_viz. It is the only Button entry in the default sidebar, so Storybook shows Button as a single 1-depth item.
export const All: Story = { export const All: Story = {
name: "Button", name: "Button",
@@ -320,45 +373,37 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Button" /> <StoryPageHeader title="Button" />
{/* Sizes | Variants on one row from a 1080px preview (1280px screen with the 180px LNB), sharing the whole width in two {/* Equal-width columns from a 1400px preview (the 420px Standard Solid matrix fits a third: 3 × 420 + 2 × 24 plus the page
equal halves (never narrower than their rows). Narrower previews stack the two sections. */} margins and the scrollbar); below that Brand → Standard Solid → Standard Outline in one column, 28px apart (the
<StorySectionGrid className="grid-cols-1 items-start min-[1080px]:grid-cols-[minmax(max-content,1fr)_minmax(max-content,1fr)]"> common section gap of StorySectionGrid). */}
<StorySection title="Sizes"> <MatrixFitProvider>
<GroupRows <StorySectionGrid className="grid-cols-1 items-start min-[1400px]:grid-cols-3">
groups={appearances.slice(0, 2).map(([appearance, label]) => ({ {appearances.map(([appearance, label]) => (
id: `sizes-${appearance}`, <StorySection key={appearance} id={`${appearance}-group`} title={label}>
label, <div className="grid min-w-0 gap-6">
children: sizes.map(([size, sizeLabel]) => ( <GroupBlock id={`sizes-${appearance}`} label="Sizes">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{sizes.map(([size, sizeLabel]) => (
<Button key={size} appearance={appearance} size={size}> <Button key={size} appearance={appearance} size={size}>
{sizeLabel} {sizeLabel}
</Button> </Button>
)), ))}
}))} </div>
/> </GroupBlock>
</StorySection> <GroupBlock id={`${appearance}-states`} label="States">
<StorySection title="Variants"> <StateMatrix appearance={appearance} states={standardStates} />
<GroupRows </GroupBlock>
groups={appearances.map(([appearance, label]) => ({ </div>
id: `roles-${appearance}`,
label,
children: rolesFor(appearance).map(([variant, color]) => (
<Button key={variant} variant={variant} appearance={appearance}>
{color}
</Button>
)),
}))}
/>
</StorySection> </StorySection>
))}
</StorySectionGrid> </StorySectionGrid>
<StorySection title="States"> {/* 2026-11-13 (user request): Toggle Button joins the Button page ("버튼") after the treatment columns, from
<GroupColumns ./toggle-examples unchanged; the Toggle Stories keep their IDs and direct URLs but are hidden from the sidebar.
groups={appearances.map(([appearance, label]) => ({ `flat`: one "Toggle Button" H2 section, 36px below the columns above, with 3 equal group columns (Variants / States,
id: `${appearance}-states`, Groups, Sizes) from 1400px like the treatments, each row in the States matrix form (label left, 8px rhythm).
label, Inside MatrixFitProvider so its rows switch to the stacked form together with the matrices. */}
children: <StateMatrix appearance={appearance} states={standardStates} />, <ToggleButtonSection />
}))} </MatrixFitProvider>
/>
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+82
View File
@@ -0,0 +1,82 @@
import { useState, type ReactNode } from "react"
import type { DateRange } from "react-day-picker"
import { Calendar } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Calendar specimens (Storybook only). Used by the Calendar Stories (verification) and by the Date Picker representative
// page, which shows Date Picker and Calendar under one sidebar item ("날짜 선택").
// Each mode uses real, clickable calendars. `today` is pinned to 2026-10-02 so the Story looks the same on any day; this uses
// react-day-picker's own `today` prop, not an HM state. Korean is the Calendar default locale.
const today = new Date(2026, 9, 2)
const month = new Date(2026, 9, 1)
const box = (node: ReactNode) => <div className="rounded-md border bg-card w-fit">{node}</div>
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
export function Single({ showValue = true }: { showValue?: boolean }) {
const [date, setDate] = useState<Date | undefined>(new Date(2026, 9, 15))
return (
<div className="grid gap-2">
{box(<Calendar mode="single" selected={date} onSelect={setDate} defaultMonth={month} today={today} />)}
{showValue ? <span className="text-sm text-muted-foreground" data-testid="single">{`선택: ${date ? date.toLocaleDateString("ko-KR") : "없음"}`}</span> : null}
</div>
)
}
export function DisabledDates() {
const [date, setDate] = useState<Date | undefined>()
return box(
<Calendar
mode="single"
selected={date}
onSelect={setDate}
defaultMonth={month}
today={today}
disabled={[{ before: new Date(2026, 9, 5) }, { dayOfWeek: [0] }]}
/>
)
}
export function Range() {
const [range, setRange] = useState<DateRange | undefined>({ from: new Date(2026, 9, 8), to: new Date(2026, 9, 14) })
return box(<Calendar mode="range" selected={range} onSelect={setRange} defaultMonth={month} today={today} />)
}
function TwoMonths() {
return box(<Calendar mode="single" numberOfMonths={2} defaultMonth={month} today={today} />)
}
// Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles and
// specimen tops line up; the wide Two months specimen takes two tracks (the full row on narrow screens).
const Grid = ({ children }: { children: ReactNode }) => (
<div className="sb-calendar-example-grid grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
)
const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?: boolean; children: ReactNode }) => (
<div className={wide ? "sb-calendar-example-wide col-span-full min-w-0 min-[1000px]:col-span-2" : "min-w-0"}>
<StateBoard min={10000} states={[{ id, label, children }]} />
</div>
)
// The Calendar sections of a representative page. `titlePrefix` names the component in each section title and `idPrefix`
// keeps the specimen ids (data-overview-state) unique when the sections share a page with other components.
export function CalendarSections({ titlePrefix, idPrefix = "" }: { titlePrefix?: string; idPrefix?: string }) {
const title = (name: string) => (titlePrefix ? `${titlePrefix} · ${name}` : name)
return (
<>
<StorySection title={title("Variants")}>
<Grid>
<Cell id={`${idPrefix}single`} label="Single"><Single showValue={false} /></Cell>
<Cell id={`${idPrefix}range`} label="Range"><Range /></Cell>
<Cell id={`${idPrefix}months`} label="Two months" wide><TwoMonths /></Cell>
</Grid>
</StorySection>
<StorySection title={title("States")}>
<Grid>
<Cell id={`${idPrefix}disabled`} label="Disabled dates"><DisabledDates /></Cell>
</Grid>
</StorySection>
</>
)
}
+9 -71
View File
@@ -1,88 +1,26 @@
import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import type { DateRange } from "react-day-picker" import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { Calendar } from "@hm-design/ui" import { CalendarSections, DisabledDates, Range, Single } from "./calendar-examples"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// The representative "Calendar" Story shows each mode with real, clickable calendars; the same specimens also exist as Stories // The Calendar specimens live in ./calendar-examples and are also shown on the Date Picker representative page ("날짜 선택"),
// tagged `verification` (hidden from the sidebar). `today` is pinned to 2026-10-02 so the Story looks the same on any day; this // the single sidebar item for both. Every Story here keeps its ID and direct URL but is hidden from the sidebar
// uses react-day-picker's own `today` prop, not an HM state. Korean is the Calendar default locale. // (tag `verification`).
const today = new Date(2026, 9, 2)
const month = new Date(2026, 9, 1)
const box = (node: ReactNode) => <div className="rounded-md border bg-card w-fit">{node}</div>
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
function Single({ showValue = true }: { showValue?: boolean }) {
const [date, setDate] = useState<Date | undefined>(new Date(2026, 9, 15))
return (
<div className="grid gap-2">
{box(<Calendar mode="single" selected={date} onSelect={setDate} defaultMonth={month} today={today} />)}
{showValue ? <span className="text-sm text-muted-foreground" data-testid="single">{`선택: ${date ? date.toLocaleDateString("ko-KR") : "없음"}`}</span> : null}
</div>
)
}
function DisabledDates() {
const [date, setDate] = useState<Date | undefined>()
return box(
<Calendar
mode="single"
selected={date}
onSelect={setDate}
defaultMonth={month}
today={today}
disabled={[{ before: new Date(2026, 9, 5) }, { dayOfWeek: [0] }]}
/>
)
}
function Range() {
const [range, setRange] = useState<DateRange | undefined>({ from: new Date(2026, 9, 8), to: new Date(2026, 9, 14) })
return box(<Calendar mode="range" selected={range} onSelect={setRange} defaultMonth={month} today={today} />)
}
function TwoMonths() {
return box(<Calendar mode="single" numberOfMonths={2} defaultMonth={month} today={today} />)
}
// Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and
// specimen tops line up; the wide Two months specimen takes two tracks (the full row on narrow screens).
const Grid = ({ children }: { children: ReactNode }) => (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
)
const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?: boolean; children: ReactNode }) => (
<div className={wide ? "col-span-full min-w-0 min-[1000px]:col-span-2" : "min-w-0"}>
<StateBoard min={10000} states={[{ id, label, children }]} />
</div>
)
// No `component`: Calendar props are a union of selection modes, so Stories without args would not type-check. // No `component`: Calendar props are a union of selection modes, so Stories without args would not type-check.
const meta = { title: "Components/Search & Date/Calendar", parameters: { layout: "padded" } } satisfies Meta const meta = { title: "Components/Search & Date/Calendar", parameters: { layout: "padded" } } satisfies Meta
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative Story: the common Story page; the sections group the same specimens. // Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Date
// Picker page shows the same sections.
export const All: Story = { export const All: Story = {
tags: ["verification"],
name: "Calendar", name: "Calendar",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Calendar" /> <StoryPageHeader title="Calendar" />
<StorySection title="Variants"> <CalendarSections />
<Grid>
<Cell id="single" label="Single"><Single showValue={false} /></Cell>
<Cell id="range" label="Range"><Range /></Cell>
<Cell id="months" label="Two months" wide><TwoMonths /></Cell>
</Grid>
</StorySection>
<StorySection title="States">
<Grid>
<Cell id="disabled" label="Disabled dates"><DisabledDates /></Cell>
</Grid>
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+1 -2
View File
@@ -143,7 +143,7 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider // (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. // has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
// The Media example spans two columns; carousels fill their column by design. // The Media example spans two columns; carousels fill their column by design.
const specimenColumns = { const specimenColumns = {
@@ -167,7 +167,6 @@ function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2
) )
} }
// Representative Story: the common Story page; the sections group the same specimens.
// Representative Story: Overview (Basic) is merged into Variants (Basic, Multiple items, Loop, Vertical), 4 columns on PC; Media stays under Usage. // Representative Story: Overview (Basic) is merged into Variants (Basic, Multiple items, Loop, Vertical), 4 columns on PC; Media stays under Usage.
export const All: Story = { export const All: Story = {
name: "Carousel", name: "Carousel",
+107
View File
@@ -0,0 +1,107 @@
import { useId, useState, type ReactNode } from "react"
import { Checkbox, Label } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Checkbox specimens (Storybook only). Used by the Checkbox Stories (verification) and by the Selection Controls
// representative page ("선택 컨트롤"), which shows Checkbox, Radio Group and Switch under one sidebar item.
// Current HM Checkbox. ui-viz has no Checkbox spec, so it was reference only.
// The option label is the HM Label; its foreground role for Checkbox options is still TBD in ui_viz.
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility
// like `text-foreground` loses to it; overriding the Label's own token on the element works. Option labels use the default
// text color; a Label next to a disabled Checkbox (`peer-disabled`) keeps the secondary color. State titles are untouched.
export const optionLabel = "[--hm-label-text:var(--foreground)] peer-disabled:[--hm-label-text:var(--hm-sem-fg-secondary)]"
export function Option({ label, children }: { label: string; children: (id: string) => ReactNode }) {
const id = useId()
return (
<div className="flex items-center gap-2">
{children(id)}
<Label htmlFor={id} className={optionLabel}>{label}</Label>
</div>
)
}
// Representative-page layout (Storybook presentation only): 3 equal columns on PC (iframe width 1000px and up), shrinking
// to fit below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so StateBoard's
// auto-fill grid cannot leave an empty or stretched track.
// `className` replaces the track (the Selection Controls page passes its shared 4-column grid).
function StateGrid({ states, className }: { states: BoardState[]; className?: string }) {
return (
<div className={className ?? "grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] min-[1000px]:grid-cols-3"}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
// The wrapping Long Label example (former Usage section of the representative page), also used by the hidden `LongLabel` Story.
export function LongLabelExample() {
return (
<div className="max-w-[220px]">
<div className="flex items-start gap-2">
<Checkbox id="cb-long" className="mt-0.5" />
<Label htmlFor="cb-long" className={optionLabel}>A very long checkbox label that wraps onto multiple lines</Label>
</div>
</div>
)
}
// Starts indeterminate; a click makes it checked (Radix), so the Indeterminate example is also the interactive one.
// Exported for the Selection Controls page's Variants section.
export function Controlled() {
const [checked, setChecked] = useState<boolean | "indeterminate">("indeterminate")
return (
<Option label="Option">
{(id) => <Checkbox id={id} checked={checked} onCheckedChange={setChecked} />}
</Option>
)
}
// The Checkbox sections of a representative page. `titlePrefix` names the component in each section title and `idPrefix`
// keeps the specimen ids (data-overview-state) unique when the sections share a page with other components. `gridClassName`
// gives every section of a shared page one column track (Selection Controls); without it the Checkbox page keeps its own.
export function CheckboxSections({ titlePrefix, idPrefix = "", gridClassName }: { titlePrefix?: string; idPrefix?: string; gridClassName?: string }) {
const title = (name: string) => (titlePrefix ? `${titlePrefix} · ${name}` : name)
return (
<>
<StorySection title={title("States")}>
<StateGrid
className={gridClassName}
states={[
{ id: `${idPrefix}unchecked`, label: "Unchecked", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
{ id: `${idPrefix}checked`, label: "Checked", children: <Option label="Option">{(id) => <Checkbox id={id} defaultChecked />}</Option> },
{ id: `${idPrefix}indeterminate`, label: "Indeterminate", children: <Controlled /> },
{
id: `${idPrefix}hover`,
label: "Hover",
// data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts).
children: (
<div className="grid gap-3">
<Option label="Unchecked">{(id) => <Checkbox id={id} data-sb-hover="" />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} defaultChecked data-sb-hover="" />}</Option>
</div>
),
},
{ id: `${idPrefix}focus`, label: "Focus", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
{
id: `${idPrefix}disabled`,
label: "Disabled",
children: (
<div className="grid gap-3">
<Option label="Unchecked">{(id) => <Checkbox id={id} disabled />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option>
</div>
),
},
]}
/>
</StorySection>
<StorySection title={title("Validation")}>
<StateGrid className={gridClassName} states={[{ id: `${idPrefix}error`, label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
</StorySection>
</>
)
}
+10 -90
View File
@@ -1,61 +1,11 @@
import { useId, useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Checkbox, Label } from "@hm-design/ui" import { Checkbox } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board" import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { CheckboxSections, LongLabelExample, Option } from "./checkbox-examples"
// Current HM Checkbox. ui-viz has no Checkbox spec, so it was reference only. // Current HM Checkbox. The specimens live in ./checkbox-examples and are also shown on the Selection Controls representative
// The option label is the HM Label; its foreground role for Checkbox options is still TBD in ui_viz. // page ("선택 컨트롤", Checkbox / Radio Group / Switch), the single sidebar item for the three. Every Story here keeps its ID and
// direct URL but is hidden from the sidebar (tag `verification`).
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility
// like `text-foreground` loses to it; overriding the Label's own token on the element works. Option labels use the default
// text color; a Label next to a disabled Checkbox (`peer-disabled`) keeps the secondary color. State titles are untouched.
const optionLabel = "[--hm-label-text:var(--foreground)] peer-disabled:[--hm-label-text:var(--hm-sem-fg-secondary)]"
function Option({ label, children }: { label: string; children: (id: string) => ReactNode }) {
const id = useId()
return (
<div className="flex items-center gap-2">
{children(id)}
<Label htmlFor={id} className={optionLabel}>{label}</Label>
</div>
)
}
// Representative-page layout (Storybook presentation only): 3 equal columns on PC (iframe width 1000px and up), shrinking
// to fit below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so StateBoard's
// auto-fill grid cannot leave an empty or stretched track.
function StateGrid({ states }: { states: BoardState[] }) {
return (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] min-[1000px]:grid-cols-3">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
// The wrapping Long Label example (former Usage section of the representative page), also used by the hidden `LongLabel` Story.
function LongLabelExample() {
return (
<div className="max-w-[220px]">
<div className="flex items-start gap-2">
<Checkbox id="cb-long" className="mt-0.5" />
<Label htmlFor="cb-long" className={optionLabel}>A very long checkbox label that wraps onto multiple lines</Label>
</div>
</div>
)
}
// Starts indeterminate; a click makes it checked (Radix), so the Indeterminate example is also the interactive one.
function Controlled() {
const [checked, setChecked] = useState<boolean | "indeterminate">("indeterminate")
return (
<Option label="Option">
{(id) => <Checkbox id={id} checked={checked} onCheckedChange={setChecked} />}
</Option>
)
}
const meta = { const meta = {
title: "Components/Form/Checkbox", title: "Components/Form/Checkbox",
@@ -73,46 +23,16 @@ const meta = {
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Selection
// Controls page shows the same sections.
export const All: Story = { export const All: Story = {
tags: ["verification"],
name: "Checkbox", name: "Checkbox",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Checkbox" /> <StoryPageHeader title="Checkbox" />
<StorySection title="States"> <CheckboxSections />
<StateGrid
states={[
{ id: "unchecked", label: "Unchecked", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
{ id: "checked", label: "Checked", children: <Option label="Option">{(id) => <Checkbox id={id} defaultChecked />}</Option> },
{ id: "indeterminate", label: "Indeterminate", children: <Controlled /> },
{
id: "hover",
label: "Hover",
// data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts).
children: (
<div className="grid gap-3">
<Option label="Unchecked">{(id) => <Checkbox id={id} data-sb-hover="" />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} defaultChecked data-sb-hover="" />}</Option>
</div>
),
},
{ id: "focus", label: "Focus", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
{
id: "disabled",
label: "Disabled",
children: (
<div className="grid gap-3">
<Option label="Unchecked">{(id) => <Checkbox id={id} disabled />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option>
</div>
),
},
]}
/>
</StorySection>
<StorySection title="Validation">
<StateGrid states={[{ id: "error", label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
</StorySection>
</StoryPage> </StoryPage>
), ),
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
+96
View File
@@ -0,0 +1,96 @@
import { useState, type ReactNode } from "react"
import { ChevronDownIcon } from "lucide-react"
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Collapsible specimens (Storybook only). Used by the Collapsible Stories (verification) and by the Accordion
// representative page, which shows Accordion and Collapsible under one sidebar item ("접기·펼치기").
// One independent disclosure per state with real, clickable triggers. Collapsible is a single disclosure: no divider system
// and no animation.
const longText = "긴 텍스트는 줄바꿈으로 전체를 표시합니다. 공백 없는 긴 문자열도 너비를 밀어내지 않고 줄바꿈합니다. ".repeat(6)
const narrow = (children: ReactNode) => <div className="w-full max-w-md">{children}</div>
function Frame({ label, defaultOpen, disabled, children }: { label: string; defaultOpen?: boolean; disabled?: boolean; children: ReactNode }) {
return narrow(
<Collapsible defaultOpen={defaultOpen} disabled={disabled}>
<CollapsibleTrigger>
{label}
<ChevronDownIcon aria-hidden />
</CollapsibleTrigger>
<CollapsibleContent>{children}</CollapsibleContent>
</Collapsible>
)
}
export const ClosedSpecimen = () => <Frame label="고급 설정 보기">추가 옵션이 여기에 표시됩니다.</Frame>
export const OpenSpecimen = () => (
<Frame label="고급 설정 보기" defaultOpen>
처음부터 열려 있는 영역입니다.
</Frame>
)
export const DisabledSpecimen = () => (
<Frame label="비활성 영역" disabled>
열 수 없습니다.
</Frame>
)
export const LongSpecimen = () => (
<Frame label="긴 콘텐츠" defaultOpen>
<p>{longText}</p>
<p className="mt-2">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</p>
</Frame>
)
// Controlled: the parent owns `open`; an HM Button is the trigger through asChild and keeps its style.
export function ControlledSpecimen() {
const [open, setOpen] = useState(false)
return narrow(
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button variant="primary" appearance="outline">
{open ? "접기" : "펼치기"}
</Button>
</CollapsibleTrigger>
{/* 2026-11-13: sample content instead of the former `controlled: open = …` debug text; the trigger label shows the state. */}
<CollapsibleContent>펼친 영역의 내용입니다.</CollapsibleContent>
</Collapsible>
)
}
const sections = [
{ id: "closed", label: "Closed", node: <ClosedSpecimen /> },
{ id: "open", label: "Open", node: <OpenSpecimen /> },
{ id: "disabled", label: "Disabled", node: <DisabledSpecimen /> },
{ id: "long", label: "Long Content", node: <LongSpecimen /> },
{ id: "controlled", label: "Button trigger", node: <ControlledSpecimen /> },
]
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), one column
// below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so the title and
// specimen start line up, unused columns stay empty and `items-start` lets an opened item grow without touching its neighbors.
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
return (
<div className={`grid grid-cols-1 items-start gap-6 ${cols === 3 ? "min-[1000px]:grid-cols-3" : "min-[1000px]:grid-cols-2"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
// The Collapsible sections of a representative page. `titlePrefix` names the component in each section title and `idPrefix`
// keeps the specimen ids (data-overview-state) unique when the sections share a page with other components.
export function CollapsibleSections({ titlePrefix, idPrefix = "" }: { titlePrefix?: string; idPrefix?: string }) {
const title = (name: string) => (titlePrefix ? `${titlePrefix} · ${name}` : name)
const board = (cols: 2 | 3, ...ids: string[]) => (
<StateGrid cols={cols} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: `${idPrefix}${s.id}`, label: s.label, children: s.node }))} />
)
return (
<>
<StorySection title={title("States")}>{board(3, "closed", "open", "disabled")}</StorySection>
<StorySection title={title("Usage")}>{board(3, "long", "controlled")}</StorySection>
</>
)
}
+10 -85
View File
@@ -1,101 +1,26 @@
import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { ChevronDownIcon } from "lucide-react" import { Collapsible } from "@hm-design/ui"
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from "@hm-design/ui" import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { StateBoard, type BoardState } from "@/components/story-board" import { ClosedSpecimen, CollapsibleSections, DisabledSpecimen, LongSpecimen, OpenSpecimen } from "./collapsible-examples"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// The representative "Collapsible" Story shows one independent disclosure per state with real, clickable triggers. The same // Collapsible is a single disclosure. The specimens live in ./collapsible-examples and are also shown on the Accordion
// sections also exist as Stories tagged `verification` (hidden from the sidebar). Collapsible is a single disclosure: no divider // representative page ("접기·펼치기"), the single sidebar item for both. Every Story here keeps its ID and direct URL but is
// system and no animation. // hidden from the sidebar (tag `verification`).
const longText = "긴 텍스트는 줄바꿈으로 전체를 표시합니다. 공백 없는 긴 문자열도 너비를 밀어내지 않고 줄바꿈합니다. ".repeat(6)
const narrow = (children: ReactNode) => <div className="w-full max-w-md">{children}</div>
function Frame({ label, defaultOpen, disabled, children }: { label: string; defaultOpen?: boolean; disabled?: boolean; children: ReactNode }) {
return narrow(
<Collapsible defaultOpen={defaultOpen} disabled={disabled}>
<CollapsibleTrigger>
{label}
<ChevronDownIcon aria-hidden />
</CollapsibleTrigger>
<CollapsibleContent>{children}</CollapsibleContent>
</Collapsible>
)
}
const ClosedSpecimen = () => <Frame label="고급 설정 보기">추가 옵션이 여기에 표시됩니다.</Frame>
const OpenSpecimen = () => (
<Frame label="고급 설정 보기" defaultOpen>
처음부터 열려 있는 영역입니다.
</Frame>
)
const DisabledSpecimen = () => (
<Frame label="비활성 영역" disabled>
열 수 없습니다.
</Frame>
)
const LongSpecimen = () => (
<Frame label="긴 콘텐츠" defaultOpen>
<p>{longText}</p>
<p className="mt-2">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA</p>
</Frame>
)
// Controlled: the parent owns `open`; an HM Button is the trigger through asChild and keeps its style.
function ControlledSpecimen() {
const [open, setOpen] = useState(false)
return narrow(
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button variant="primary" appearance="outline">
{open ? "접기" : "펼치기"}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>{`controlled: open = ${String(open)}`}</CollapsibleContent>
</Collapsible>
)
}
const sections = [
{ id: "closed", label: "Closed", node: <ClosedSpecimen /> },
{ id: "open", label: "Open", node: <OpenSpecimen /> },
{ id: "disabled", label: "Disabled", node: <DisabledSpecimen /> },
{ id: "long", label: "Long Content", node: <LongSpecimen /> },
{ id: "controlled", label: "Button trigger", node: <ControlledSpecimen /> },
]
const meta = { title: "Components/Disclosure/Collapsible", component: Collapsible, parameters: { layout: "padded" } } satisfies Meta<typeof Collapsible> const meta = { title: "Components/Disclosure/Collapsible", component: Collapsible, parameters: { layout: "padded" } } satisfies Meta<typeof Collapsible>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), one column // Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Accordion
// below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so the title, divider and // page shows the same sections.
// specimen start line up, unused columns stay empty and `items-start` lets an opened item grow without touching its neighbors.
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
return (
<div className={`grid grid-cols-1 items-start gap-6 ${cols === 3 ? "min-[1000px]:grid-cols-3" : "min-[1000px]:grid-cols-2"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
const board = (cols: 2 | 3, ...ids: string[]) => (
<StateGrid cols={cols} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative Story: the common Story page; the sections group the same specimens.
export const All: Story = { export const All: Story = {
tags: ["verification"],
name: "Collapsible", name: "Collapsible",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Collapsible" /> <StoryPageHeader title="Collapsible" />
<StorySection title="States">{board(3, "closed", "open", "disabled")}</StorySection> <CollapsibleSections />
<StorySection title="Usage">{board(3, "long", "controlled")}</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+5 -17
View File
@@ -1,7 +1,6 @@
import { useState, type ReactNode } from "react" import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Combobox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type ComboboxOption } from "@hm-design/ui" import { Combobox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type ComboboxOption } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Combobox is a Popover + Command composition (search + single choice); Select stays for short lists without search. // Combobox is a Popover + Command composition (search + single choice); Select stays for short lists without search.
@@ -106,17 +105,6 @@ function SelectComparison() {
) )
} }
// Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and
// fields line up; sections with fewer specimens leave the remaining tracks empty. A wide cell takes two tracks (the full row on narrow screens).
const Grid = ({ children }: { children: ReactNode }) => (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
)
const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?: boolean; children: ReactNode }) => (
<div className={wide ? "col-span-full min-w-0 min-[1000px]:col-span-2" : "min-w-0"}>
<StateBoard min={10000} states={[{ id, label, children }]} />
</div>
)
// Sizes / States (2026-11-12): the Input / Select field grid with the common section indent — up to 3 tracks of at least 240px // Sizes / States (2026-11-12): the Input / Select field grid with the common section indent — up to 3 tracks of at least 240px
// within 1120px, narrower widths drop to 2 / 1 columns. Each cell is one field whose Label is the state name. // within 1120px, narrower widths drop to 2 / 1 columns. Each cell is one field whose Label is the state name.
const fieldColumns = const fieldColumns =
@@ -151,7 +139,7 @@ export const All: Story = {
<StorySection title="Sizes"> <StorySection title="Sizes">
<FieldRow <FieldRow
fields={[ fields={[
{ id: "empty", render: <Empty state="Default" /> }, { id: "empty", render: <Empty state="Large" /> },
{ id: "medium", render: <Medium state="Medium" /> }, { id: "medium", render: <Medium state="Medium" /> },
]} ]}
/> />
@@ -165,10 +153,10 @@ export const All: Story = {
/> />
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<Grid> <div className="sb-page-indent grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">
<Cell id="long" label="Long option"><LongLabel /></Cell> <div className="min-w-0" data-overview-state="long"><LongLabel /></div>
<Cell id="select-comparison" label="Select / Combobox" wide><SelectComparison /></Cell> <div className="col-span-full min-w-0 min-[1000px]:col-span-2" data-overview-state="select-comparison"><SelectComparison /></div>
</Grid> </div>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+2 -2
View File
@@ -59,7 +59,7 @@ function Grouped() {
환경설정 환경설정
<CommandShortcut>⌘,</CommandShortcut> <CommandShortcut>⌘,</CommandShortcut>
</CommandItem> </CommandItem>
<CommandItem disabled>결제 (사용 불가)</CommandItem> <CommandItem disabled>결제</CommandItem>
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>
@@ -80,7 +80,7 @@ function Long() {
) )
} }
// Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and // Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles and
// command boxes line up; sections with fewer specimens leave the remaining tracks empty. // command boxes line up; sections with fewer specimens leave the remaining tracks empty.
const Grid = ({ children }: { children: ReactNode }) => ( const Grid = ({ children }: { children: ReactNode }) => (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div> <div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
+72
View File
@@ -0,0 +1,72 @@
import { useState } from "react"
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@hm-design/ui"
// Shared Context Menu specimen (Storybook only). Used by the Context Menu Stories (verification) and by the Dropdown Menu
// representative page, which shows both menus under one sidebar item ("팝업 메뉴").
// Context Menu opens from pointer context (right click, the context-menu key / Shift+F10, long press) on its trigger area.
// Radix owns keyboard navigation, focus return and Escape.
export function ContextMenuFull() {
const [showStatus, setShowStatus] = useState(true)
const [showPanel, setShowPanel] = useState(false)
const [position, setPosition] = useState("bottom")
return (
<ContextMenu>
{/* tabIndex lets keyboard users focus the area and open the menu with Shift+F10 / the context-menu key; Radix Trigger is a plain span. */}
<ContextMenuTrigger tabIndex={0} className="flex h-40 w-80 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
이 영역에서 오른쪽 클릭
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuLabel>파일</ContextMenuLabel>
<ContextMenuGroup>
<ContextMenuItem>
열기 <ContextMenuShortcut>⌘O</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
이름 바꾸기 <ContextMenuShortcut>F2</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem disabled>붙여넣기</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuLabel>보기</ContextMenuLabel>
<ContextMenuCheckboxItem checked={showStatus} onCheckedChange={setShowStatus}>
상태 표시줄
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={showPanel} onCheckedChange={setShowPanel}>
패널
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuLabel>위치</ContextMenuLabel>
<ContextMenuRadioGroup value={position} onValueChange={setPosition}>
<ContextMenuRadioItem value="top">상단</ContextMenuRadioItem>
<ContextMenuRadioItem value="bottom">하단</ContextMenuRadioItem>
<ContextMenuRadioItem value="right">오른쪽</ContextMenuRadioItem>
</ContextMenuRadioGroup>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>더 보기</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>이메일로 공유</ContextMenuItem>
<ContextMenuItem>링크 복사</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">삭제</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
+8 -70
View File
@@ -1,83 +1,21 @@
import { useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { import { ContextMenu } from "@hm-design/ui"
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@hm-design/ui"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { ContextMenuFull as Full } from "./context-menu-examples"
// Context Menu opens from pointer context (right click, the context-menu key / Shift+F10, long press) on its trigger area. // Context Menu opens from pointer context (right click, the context-menu key / Shift+F10, long press) on its trigger area.
// The representative Story is a target area: right click it. Radix owns keyboard navigation, focus return and Escape. // The specimen lives in ./context-menu-examples and is also shown on the Dropdown Menu representative page, which is the single
// sidebar item for both ("팝업 메뉴"). Every Story here keeps its ID and direct URL but is hidden from the sidebar.
function Full() {
const [showStatus, setShowStatus] = useState(true)
const [showPanel, setShowPanel] = useState(false)
const [position, setPosition] = useState("bottom")
return (
<ContextMenu>
{/* tabIndex lets keyboard users focus the area and open the menu with Shift+F10 / the context-menu key; Radix Trigger is a plain span. */}
<ContextMenuTrigger tabIndex={0} className="flex h-40 w-80 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
이 영역에서 오른쪽 클릭
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuLabel>파일</ContextMenuLabel>
<ContextMenuGroup>
<ContextMenuItem>
열기 <ContextMenuShortcut>⌘O</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
이름 바꾸기 <ContextMenuShortcut>F2</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem disabled>붙여넣기 (사용 불가)</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuLabel>보기</ContextMenuLabel>
<ContextMenuCheckboxItem checked={showStatus} onCheckedChange={setShowStatus}>
상태 표시줄
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem checked={showPanel} onCheckedChange={setShowPanel}>
패널
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuLabel>위치</ContextMenuLabel>
<ContextMenuRadioGroup value={position} onValueChange={setPosition}>
<ContextMenuRadioItem value="top">상단</ContextMenuRadioItem>
<ContextMenuRadioItem value="bottom">하단</ContextMenuRadioItem>
<ContextMenuRadioItem value="right">오른쪽</ContextMenuRadioItem>
</ContextMenuRadioGroup>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>더 보기</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>이메일로 공유</ContextMenuItem>
<ContextMenuItem>링크 복사</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">삭제</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
const meta = { title: "Components/Overlay/Context Menu", component: ContextMenu, parameters: { layout: "centered" } } satisfies Meta<typeof ContextMenu> const meta = { title: "Components/Overlay/Context Menu", component: ContextMenu, parameters: { layout: "centered" } } satisfies Meta<typeof ContextMenu>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative Story: right click the area to open the full menu (item, disabled, separator, shortcut, checkbox, radio group, sub menu, destructive). // Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Dropdown Menu
// page shows the same specimen. Right click the area to open the full menu (item, disabled, separator, shortcut, checkbox, radio
// group, sub menu, destructive).
export const All: Story = { export const All: Story = {
tags: ["verification"],
name: "Context Menu", name: "Context Menu",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
+99
View File
@@ -0,0 +1,99 @@
.sb-page.sb-date-picker {
--sb-page-section-gap: 24px;
--sb-date-picker-field-max: 358px;
/* 276px Calendar plus the existing 1px border on each side. */
--sb-date-picker-calendar-width: 278px;
--sb-date-picker-column-gap: 16px;
}
.sb-date-picker > .sb-page-inner {
container: date-picker-story / inline-size;
}
.sb-date-picker-fields {
display: grid;
grid-template-columns: minmax(0, var(--sb-date-picker-field-max));
align-items: start;
gap: var(--sb-date-picker-column-gap);
}
.sb-date-picker-fields--usage > * {
max-width: 320px;
}
.sb-date-picker .sb-date-picker-calendars {
column-gap: var(--sb-date-picker-column-gap);
}
.sb-date-picker-calendars .sb-calendar-example-grid {
grid-template-columns: minmax(0, 1fr);
gap: var(--sb-date-picker-column-gap);
}
.sb-date-picker-calendars .sb-calendar-example-grid > * {
overflow-x: auto;
}
.sb-date-picker-calendars .sb-calendar-example-wide {
grid-column: 1 / -1;
justify-self: start;
width: fit-content;
max-width: 100%;
}
/* Two field tracks fit after the Storybook indent. */
@container date-picker-story (min-width: 512px) {
.sb-date-picker-fields {
grid-template-columns: repeat(2, minmax(0, var(--sb-date-picker-field-max)));
}
}
@container date-picker-story (min-width: 588px) {
.sb-date-picker-calendars .sb-calendar-example-grid:has(.sb-calendar-example-wide) {
grid-template-columns: repeat(2, var(--sb-date-picker-calendar-width));
}
}
/* Four calendar-width tracks + three 16px gaps + the 16px Storybook indent. */
@container date-picker-story (min-width: 1176px) {
.sb-date-picker-fields,
.sb-date-picker .sb-date-picker-calendars {
grid-template-columns: repeat(4, minmax(var(--sb-date-picker-calendar-width), var(--sb-date-picker-field-max)));
}
/* Subgrids align the group headings and first specimen row without flattening either section. */
.sb-date-picker .sb-date-picker-calendars {
row-gap: 0;
padding-left: var(--sb-page-indent);
}
.sb-date-picker-calendars > .sb-page-section,
.sb-date-picker-calendars .sb-page-section-body,
.sb-date-picker-calendars .sb-calendar-example-grid {
display: grid;
grid-template: subgrid / subgrid;
}
.sb-date-picker-calendars > .sb-page-section:first-child {
grid-area: 1 / 1 / span 3 / span 2;
}
.sb-date-picker-calendars > .sb-page-section:last-child {
grid-area: 1 / 3 / span 2;
}
.sb-date-picker-calendars .sb-page-section-body {
grid-area: 2 / 1 / -1 / -1;
}
.sb-date-picker .sb-date-picker-calendars .sb-page-section-body > .sb-calendar-example-grid {
grid-area: 1 / 1 / -1 / -1;
grid-template-columns: subgrid;
padding-left: 0;
row-gap: 0;
}
.sb-date-picker-calendars .sb-calendar-example-wide {
margin-top: var(--sb-date-picker-column-gap);
}
}
+26 -39
View File
@@ -1,8 +1,9 @@
import { useState, type ReactNode } from "react" import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { DatePicker } from "@hm-design/ui" import { DatePicker } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { CalendarSections } from "./calendar-examples"
import "./date-picker-story.css"
// DatePicker is a Popover + Calendar composition packaged as a component (value handling, trigger text, close on select). // DatePicker is a Popover + Calendar composition packaged as a component (value handling, trigger text, close on select).
// The representative Story shows real pickers; the same specimens also exist as Stories tagged `verification` (hidden from the // The representative Story shows real pickers; the same specimens also exist as Stories tagged `verification` (hidden from the
@@ -46,19 +47,14 @@ function Selected({ state }: { state?: string }) {
) )
} }
function DefaultFormat() { function FormatExample({ id, triggerId, label, children }: { id: string; triggerId: string; label: string; children: ReactNode }) {
return ( return (
<Field label="기본 형식 · YYYY.MM.DD" id="dp-padded"> <section className="grid min-w-0 content-start gap-3" data-overview-state={id}>
<DatePicker id="dp-padded" defaultValue={new Date(2026, 0, 9)} calendarProps={calendarProps} /> <h3 className="text-sm font-medium text-muted-foreground">
</Field> <label id={`${triggerId}-label`} htmlFor={triggerId}>{label}</label>
) </h3>
} {children}
</section>
function CustomFormat() {
return (
<Field label="Custom format override" id="dp-custom">
<DatePicker id="dp-custom" defaultValue={new Date(2026, 9, 6)} format={(date) => date.toLocaleDateString("ko-KR")} calendarProps={calendarProps} />
</Field>
) )
} }
@@ -113,33 +109,15 @@ function Disabled({ state }: { state?: string }) {
) )
} }
// Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and
// fields line up; sections with fewer specimens leave the remaining tracks empty.
const Grid = ({ children }: { children: ReactNode }) => (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
)
const Cell = ({ id, label, children }: { id: string; label: string; children: ReactNode }) => (
<div className="min-w-0">
<StateBoard min={10000} states={[{ id, label, children }]} />
</div>
)
// Sizes / States (2026-11-12): the Input / Select field grid with the common section indent — up to 3 tracks of at least 240px
// within 1120px, narrower widths drop to 2 / 1 columns. Each cell is one field whose Label is the state name.
const fieldColumns =
"grid max-w-[1120px] items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(max(240px,calc((100%_-_48px)/3)),1fr))]"
function FieldRow({ fields }: { fields: { id: string; render: ReactNode }[] }) { function FieldRow({ fields }: { fields: { id: string; render: ReactNode }[] }) {
return ( return (
<div className="sb-page-indent"> <div className="sb-page-indent sb-date-picker-fields">
<div className={fieldColumns}>
{fields.map(({ id, render }) => ( {fields.map(({ id, render }) => (
<div key={id} className="min-w-0" data-overview-state={id}> <div key={id} className="min-w-0" data-overview-state={id}>
{render} {render}
</div> </div>
))} ))}
</div> </div>
</div>
) )
} }
@@ -148,16 +126,18 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// 2026-11-13 (user request): it also shows the Calendar sections from ./calendar-examples (titles "Calendar · …"), so
// "날짜 선택" is the one sidebar item for both; the Calendar Stories keep their IDs and direct URLs but are hidden from the sidebar.
export const All: Story = { export const All: Story = {
name: "Date Picker", name: "Date Picker",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage className="sb-date-picker">
<StoryPageHeader title="Date Picker" /> <StoryPageHeader title="Date Picker" />
<StorySection title="Sizes"> <StorySection title="Sizes">
<FieldRow <FieldRow
fields={[ fields={[
{ id: "empty", render: <Empty state="Default" /> }, { id: "empty", render: <Empty state="Large" /> },
{ id: "medium", render: <Medium state="Medium" /> }, { id: "medium", render: <Medium state="Medium" /> },
]} ]}
/> />
@@ -173,11 +153,18 @@ export const All: Story = {
/> />
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<Grid> <div className="sb-page-indent sb-date-picker-fields sb-date-picker-fields--usage">
<Cell id="format-default" label="Default format"><DefaultFormat /></Cell> <FormatExample id="format-default" triggerId="dp-padded" label="Default format">
<Cell id="format-custom" label="Custom format"><CustomFormat /></Cell> <DatePicker id="dp-padded" aria-labelledby="dp-padded-label" defaultValue={new Date(2026, 0, 9)} calendarProps={calendarProps} />
</Grid> </FormatExample>
<FormatExample id="format-custom" triggerId="dp-custom" label="Custom format">
<DatePicker id="dp-custom" aria-labelledby="dp-custom-label" defaultValue={new Date(2026, 9, 6)} format={(date) => date.toLocaleDateString("ko-KR")} calendarProps={calendarProps} />
</FormatExample>
</div>
</StorySection> </StorySection>
<StorySectionGrid className="sb-date-picker-calendars">
<CalendarSections titlePrefix="Calendar" idPrefix="calendar-" />
</StorySectionGrid>
</StoryPage> </StoryPage>
), ),
} }
+17 -9
View File
@@ -6,8 +6,9 @@ import { Input } from "@hm-design/ui"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@hm-design/ui" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { AlertDialogConfirmation, AlertDialogDestructive } from "./alert-dialog-examples"
// Dialog is an overlay (Radix Portal), so it does not fit the StateBoard. The representative Story is a row of triggers that open // Dialog is an overlay (Radix Portal): each trigger sits in its own single-column StateBoard (SpecimenGrid) and opens
// the real Dialog for each ui_viz case (dialog.md §9 Reference Viewer examples); the same cases also exist as separate Stories tagged // the real Dialog for each ui_viz case (dialog.md §9 Reference Viewer examples); the same cases also exist as separate Stories tagged
// `verification` that start open. Pseudo-state helpers are not used to fake an open Dialog. // `verification` that start open. Pseudo-state helpers are not used to fake an open Dialog.
// Usage: native label below is a preserved Reference fixture. New HM React fields use public Label // Usage: native label below is a preserved Reference fixture. New HM React fields use public Label
@@ -176,16 +177,15 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative Story: the common Story page; every case opens from its trigger. Named after the component, as in ui_viz. // Representative Story: the common Story page; every case opens from its trigger. Named after the component, as in ui_viz.
// It is the only Dialog entry in the default sidebar, so Storybook shows Dialog as a single 1-depth item. // It is the only Dialog entry in the default sidebar, so Storybook shows Dialog as a single 1-depth item. It also shows the Alert
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Dialog (confirmation / warning) specimens from ./alert-dialog-examples, so "대화상자" is the one sidebar item for both; the Alert
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider // Dialog Stories keep their IDs and direct URLs but are hidden from the sidebar.
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its // Keep the board's accessible headings and shared indent, with only the triggers visible in the four-column grid.
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them. function SpecimenGrid({ states }: { states: BoardState[] }) {
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
return ( return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}> <div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4 [&_[data-overview-state]>header]:sr-only">
{states.map((state) => ( {states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} /> <StateBoard key={state.id} min={10000} states={[{ ...state, children: <div className="flex items-start">{state.children}</div> }]} />
))} ))}
</div> </div>
) )
@@ -216,6 +216,14 @@ export const All: Story = {
]} ]}
/> />
</StorySection> </StorySection>
<StorySection title="Alert Dialog">
<SpecimenGrid
states={[
{ id: "alert-confirmation", label: "Confirmation", children: <AlertDialogConfirmation /> },
{ id: "alert-destructive", label: "Destructive", children: <AlertDialogDestructive /> },
]}
/>
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+127
View File
@@ -0,0 +1,127 @@
import type { ReactNode } from "react"
// Usage: preserved native label fixtures do not define a new-screen exception. Use public Label
// for supported fields (docs/components/label.md §5); copy only actions requested by the screen.
import { Button, Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, Input } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Drawer specimens (Storybook only). Used by the Drawer Stories (verification) and by the Sheet representative page,
// which shows Sheet and Drawer under one sidebar item ("시트·드로어").
// Drawer is a modal vaul overlay (Portal). The sections open the real Drawer from its triggers; drag the handle or the panel
// down to dismiss. The same cases start open in the verification Stories. vaul owns the drag, Radix the focus and ARIA.
const longText = "긴 내용은 Body 영역만 세로로 scroll 하고 Header와 Footer는 그대로 보입니다. "
function Frame({
trigger,
defaultOpen,
title,
description,
children,
actions,
}: {
trigger: string
defaultOpen?: boolean
title: string
description?: string
children: ReactNode
actions?: ReactNode
}) {
return (
<Drawer defaultOpen={defaultOpen}>
<DrawerTrigger asChild>
<Button variant="primary" appearance="outline">
{trigger}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<DrawerBody>{children}</DrawerBody>
<DrawerFooter>{actions}</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
const actions = (
<>
<DrawerClose asChild>
<Button variant="cancel" appearance="solid">
취소
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button variant="primary" appearance="solid">
실행
</Button>
</DrawerClose>
</>
)
export function DrawerBasic({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="기본" defaultOpen={defaultOpen} title="작업 선택" description="아래에서 올라오는 패널입니다. 핸들을 아래로 끌어 닫을 수 있습니다." actions={actions}>
<p>짧은 내용은 내용 높이만큼만 차지합니다.</p>
</Frame>
)
}
export function DrawerForm({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={actions}>
<div className="grid gap-2">
<label htmlFor="drawer-form-name" className="text-sm font-medium text-foreground">
프로젝트명
</label>
<Input id="drawer-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
</div>
</Frame>
)
}
export function DrawerLong({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="긴 내용" defaultOpen={defaultOpen} title="긴 내용" description="Body만 scroll 합니다." actions={actions}>
{Array.from({ length: 30 }, (_, i) => (
<p key={i} className="mb-3">
{longText}
</p>
))}
</Frame>
)
}
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, hideSpecimenLabels }: { states: BoardState[]; hideSpecimenLabels: boolean }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4${hideSpecimenLabels ? " [&_[data-overview-state]>header]:sr-only" : ""}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div className={hideSpecimenLabels ? "flex items-start" : undefined}>{state.children}</div> }]} />
))}
</div>
)
}
// The Drawer section of a representative page: Overview and Usage merged into one Usage section (Basic, Form, Long Content).
// On a shared page `title` names the section after the component (as "Alert Dialog" on the Dialog page) and `idPrefix`
// keeps the specimen ids (data-overview-state) unique.
export function DrawerSections({ title = "Usage", idPrefix = "", hideSpecimenLabels = false }: { title?: string; idPrefix?: string; hideSpecimenLabels?: boolean }) {
return (
<StorySection title={title}>
<SpecimenGrid
hideSpecimenLabels={hideSpecimenLabels}
states={[
{ id: `${idPrefix}basic`, label: "Basic", children: <DrawerBasic /> },
{ id: `${idPrefix}form`, label: "Form", children: <DrawerForm /> },
{ id: `${idPrefix}long-content`, label: "Long Content", children: <DrawerLong /> },
]}
/>
</StorySection>
)
}
+13 -118
View File
@@ -1,135 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
// Usage: preserved native label fixtures do not define a new-screen exception. Use public Label import { Drawer } from "@hm-design/ui"
// for supported fields (docs/components/label.md §5); copy only actions requested by the screen. import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { Button, Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, Input } from "@hm-design/ui" import { DrawerBasic, DrawerForm, DrawerLong, DrawerSections } from "./drawer-examples"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Drawer is a modal vaul overlay (Portal). The representative Story opens the real Drawer from its triggers; drag the handle or // Drawer is a modal vaul overlay (Portal). The specimens live in ./drawer-examples and are also shown on the Sheet
// the panel down to dismiss. The same cases start open in the verification Stories. vaul owns the drag, Radix the focus and ARIA. // representative page ("시트·드로어"), the single sidebar item for both. Every Story here keeps its ID and direct URL but is
// hidden from the sidebar (tag `verification`).
const longText = "긴 내용은 Body 영역만 세로로 scroll 하고 Header와 Footer는 그대로 보입니다. "
function Frame({
trigger,
defaultOpen,
title,
description,
children,
actions,
}: {
trigger: string
defaultOpen?: boolean
title: string
description?: string
children: React.ReactNode
actions?: React.ReactNode
}) {
return (
<Drawer defaultOpen={defaultOpen}>
<DrawerTrigger asChild>
<Button variant="primary" appearance="outline">
{trigger}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{title}</DrawerTitle>
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
</DrawerHeader>
<DrawerBody>{children}</DrawerBody>
<DrawerFooter>{actions}</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
const actions = (
<>
<DrawerClose asChild>
<Button variant="cancel" appearance="solid">
취소
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button variant="primary" appearance="solid">
실행
</Button>
</DrawerClose>
</>
)
function Basic({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="기본" defaultOpen={defaultOpen} title="작업 선택" description="아래에서 올라오는 패널입니다. 핸들을 아래로 끌어 닫을 수 있습니다." actions={actions}>
<p>짧은 내용은 내용 높이만큼만 차지합니다.</p>
</Frame>
)
}
function FormCase({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={actions}>
<div className="grid gap-2">
<label htmlFor="drawer-form-name" className="text-sm font-medium text-foreground">
프로젝트명
</label>
<Input id="drawer-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
</div>
</Frame>
)
}
function LongCase({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<Frame trigger="긴 내용" defaultOpen={defaultOpen} title="긴 내용" description="Body만 scroll 합니다." actions={actions}>
{Array.from({ length: 30 }, (_, i) => (
<p key={i} className="mb-3">
{longText}
</p>
))}
</Frame>
)
}
const meta = { title: "Components/Overlay/Drawer", component: Drawer, parameters: { layout: "centered" } } satisfies Meta<typeof Drawer> const meta = { title: "Components/Overlay/Drawer", component: Drawer, parameters: { layout: "centered" } } satisfies Meta<typeof Drawer>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Sheet
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider // page shows the same section.
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: Overview and Usage are merged into one Usage section (Basic, Form, Long Content).
export const All: Story = { export const All: Story = {
tags: ["verification"],
name: "Drawer", name: "Drawer",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Drawer" /> <StoryPageHeader title="Drawer" />
<StorySection title="Usage"> <DrawerSections />
<SpecimenGrid
states={[
{ id: "basic", label: "Basic", children: <Basic /> },
{ id: "form", label: "Form", children: <FormCase /> },
{ id: "long-content", label: "Long Content", children: <LongCase /> },
]}
/>
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
export const Open: Story = { tags: ["verification"], render: () => <Basic defaultOpen /> } export const Open: Story = { tags: ["verification"], render: () => <DrawerBasic defaultOpen /> }
export const Form: Story = { tags: ["verification"], render: () => <FormCase defaultOpen /> } export const Form: Story = { tags: ["verification"], render: () => <DrawerForm defaultOpen /> }
export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => <LongCase defaultOpen /> } export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => <DrawerLong defaultOpen /> }
+11 -4
View File
@@ -18,9 +18,12 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@hm-design/ui" } from "@hm-design/ui"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { ContextMenuFull } from "./context-menu-examples"
// Dropdown Menu is a Radix overlay (Portal), so the representative Story is a trigger that opens the real menu, the same way as // Dropdown Menu is a Radix overlay (Portal), so the representative Story is a trigger that opens the real menu, the same way as
// Dialog. The verification Story starts open. Radix owns keyboard navigation, focus return and Escape. // Dialog. The verification Story starts open. Radix owns keyboard navigation, focus return and Escape.
// The representative page also shows the Context Menu specimen from ./context-menu-examples, so "팝업 메뉴" is the one
// sidebar item for both; the Context Menu Stories keep their IDs and direct URLs but are hidden from the sidebar.
function Full({ defaultOpen }: { defaultOpen?: boolean }) { function Full({ defaultOpen }: { defaultOpen?: boolean }) {
const [showStatus, setShowStatus] = useState(true) const [showStatus, setShowStatus] = useState(true)
@@ -42,7 +45,7 @@ function Full({ defaultOpen }: { defaultOpen?: boolean }) {
<DropdownMenuItem> <DropdownMenuItem>
설정 <DropdownMenuShortcut>⌘,</DropdownMenuShortcut> 설정 <DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled>결제 (사용 불가)</DropdownMenuItem> <DropdownMenuItem disabled>결제</DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel>보기</DropdownMenuLabel> <DropdownMenuLabel>보기</DropdownMenuLabel>
@@ -78,18 +81,22 @@ const meta = { title: "Components/Overlay/Dropdown Menu", component: DropdownMen
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative Story: the trigger opens the full menu (item, disabled, separator, checkbox, radio group, sub menu, destructive). // Representative Story: the trigger opens the full menu (item, disabled, separator, checkbox, radio group, sub menu, destructive);
// right click the Context Menu area to open the same kinds of items.
export const All: Story = { export const All: Story = {
name: "Dropdown Menu", name: "Dropdown Menu",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader <StoryPageHeader
title="Dropdown Menu" title="Dropdown Menu · Context Menu"
/> />
<StorySection title="Basic"> <StorySection title="Dropdown Menu">
<Full /> <Full />
</StorySection> </StorySection>
<StorySection title="Context Menu">
<ContextMenuFull />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+80
View File
@@ -0,0 +1,80 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Hover Card specimens (Storybook only). Used by the Hover Card Stories (verification) and by the Tooltip representative
// page, which shows Tooltip and Hover Card under one sidebar item ("툴팁·호버 카드").
// Hover Card is a Radix overlay (Portal) for a richer preview than Tooltip. The section shows real triggers (hover or focus to
// open); the verification Story starts one open. Radix owns the open / close delay and the hover bridge.
const longText = "긴 콘텐츠는 카드 폭에서 줄바꿈되고 카드 높이만 늘어납니다. 공백 없는 긴 문자열도 폭을 밀어내지 않습니다. ".repeat(4)
export function HoverCardPreview({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<HoverCard defaultOpen={defaultOpen}>
<HoverCardTrigger asChild>
<a href="#preview" className="text-sm font-medium text-primary underline underline-offset-4">
한맥 프로젝트
</a>
</HoverCardTrigger>
<HoverCardContent>
<div className="grid gap-1">
<p className="font-bold">한맥 프로젝트</p>
<p>노선과 시설물 정보를 관리하는 프로젝트입니다.</p>
<p className="pt-1 text-xs text-muted-foreground">담당 김한맥 · 최근 수정 2026-10-02</p>
</div>
</HoverCardContent>
</HoverCard>
)
}
function LongContent() {
return (
<HoverCard>
<HoverCardTrigger asChild>
<a href="#long" className="text-sm font-medium text-primary underline underline-offset-4">
긴 콘텐츠
</a>
</HoverCardTrigger>
<HoverCardContent>
<div className="grid gap-1">
<p className="font-bold">긴 콘텐츠</p>
<p>{longText}</p>
</div>
</HoverCardContent>
</HoverCard>
)
}
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
// `columns` lets a shared page keep the column track of its other sections.
function SpecimenGrid({ states, columns = 4, hideSpecimenLabels }: { states: BoardState[]; columns?: 4 | 5; hideSpecimenLabels?: boolean }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}${hideSpecimenLabels ? " [&_[data-overview-state]>header]:sr-only" : ""}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div className={hideSpecimenLabels ? "flex items-start" : undefined}>{state.children}</div> }]} />
))}
</div>
)
}
// The Hover Card section of a representative page: Overview and Usage merged into one Variants section (Basic, Long Content).
// On a shared page `title` names the section after the component (as "Alert Dialog" on the Dialog page), `columns` follows the
// page's track and `idPrefix` keeps the specimen ids (data-overview-state) unique.
export function HoverCardSections({ title = "Variants", idPrefix = "", columns = 4, hideSpecimenLabels = false }: { title?: string; idPrefix?: string; columns?: 4 | 5; hideSpecimenLabels?: boolean }) {
return (
<StorySection title={title}>
<SpecimenGrid
columns={columns}
hideSpecimenLabels={hideSpecimenLabels}
states={[
{ id: `${idPrefix}basic`, label: "Basic", children: <HoverCardPreview /> },
{ id: `${idPrefix}long-content`, label: "Long Content", children: <LongContent /> },
]}
/>
</StorySection>
)
}
+11 -68
View File
@@ -1,83 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hm-design/ui" import { HoverCard } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board" import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { HoverCardPreview, HoverCardSections } from "./hover-card-examples"
// Hover Card is a Radix overlay (Portal) for a richer preview than Tooltip. The representative Story shows real triggers // Hover Card is a Radix overlay (Portal) for a richer preview than Tooltip. The specimens live in ./hover-card-examples and
// (hover or focus to open); the verification Story starts one open. Radix owns the open / close delay and the hover bridge. // are also shown on the Tooltip representative page ("툴팁·호버 카드"), the single sidebar item for both. Every Story here
// keeps its ID and direct URL but is hidden from the sidebar (tag `verification`).
const longText = "긴 콘텐츠는 카드 폭에서 줄바꿈되고 카드 높이만 늘어납니다. 공백 없는 긴 문자열도 폭을 밀어내지 않습니다. ".repeat(4)
function Preview({ defaultOpen }: { defaultOpen?: boolean }) {
return (
<HoverCard defaultOpen={defaultOpen}>
<HoverCardTrigger asChild>
<a href="#preview" className="text-sm font-medium text-primary underline underline-offset-4">
한맥 프로젝트
</a>
</HoverCardTrigger>
<HoverCardContent>
<div className="grid gap-1">
<p className="font-bold">한맥 프로젝트</p>
<p>노선과 시설물 정보를 관리하는 프로젝트입니다.</p>
<p className="pt-1 text-xs text-muted-foreground">담당 김한맥 · 최근 수정 2026-10-02</p>
</div>
</HoverCardContent>
</HoverCard>
)
}
function LongContent() {
return (
<HoverCard>
<HoverCardTrigger asChild>
<a href="#long" className="text-sm font-medium text-primary underline underline-offset-4">
긴 콘텐츠
</a>
</HoverCardTrigger>
<HoverCardContent>
<div className="grid gap-1">
<p className="font-bold">긴 콘텐츠</p>
<p>{longText}</p>
</div>
</HoverCardContent>
</HoverCard>
)
}
const meta = { title: "Components/Overlay/Hover Card", component: HoverCard, parameters: { layout: "centered" } } satisfies Meta<typeof HoverCard> const meta = { title: "Components/Overlay/Hover Card", component: HoverCard, parameters: { layout: "centered" } } satisfies Meta<typeof HoverCard>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Tooltip
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider // page shows the same section.
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: Overview and Usage are merged into one Variants section (Basic, Long Content).
export const All: Story = { export const All: Story = {
tags: ["verification"],
name: "Hover Card", name: "Hover Card",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Hover Card" /> <StoryPageHeader title="Hover Card" />
<StorySection title="Variants"> <HoverCardSections />
<SpecimenGrid
states={[
{ id: "basic", label: "Basic", children: <Preview /> },
{ id: "long-content", label: "Long Content", children: <LongContent /> },
]}
/>
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
@@ -87,7 +30,7 @@ export const Open: Story = {
parameters: { layout: "fullscreen" }, parameters: { layout: "fullscreen" },
render: () => ( render: () => (
<div className="flex min-h-[260px] justify-center p-8"> <div className="flex min-h-[260px] justify-center p-8">
<Preview defaultOpen /> <HoverCardPreview defaultOpen />
</div> </div>
), ),
} }
+3 -1
View File
@@ -163,7 +163,9 @@ export const All: Story = {
<FieldRow <FieldRow
fields={[ fields={[
{ id: "focus", label: "Focus", props: { size: "medium", placeholder } }, { id: "focus", label: "Focus", props: { size: "medium", placeholder } },
{ id: "disabled", label: "Disabled", props: { disabled: true, defaultValue: "Unavailable project" } }, // 2026-11-13: Disabled is Medium like Focus and the Validation row (34px). Readonly keeps the component's own rule
// (packages/ui input.css: text only, always Large metrics whatever the size), so its size prop is not set.
{ id: "disabled", label: "Disabled", props: { size: "medium", disabled: true, defaultValue: "Unavailable project" } },
{ id: "readonly", label: "Readonly", props: { readOnly: true, value: "Confirmed project" } }, { id: "readonly", label: "Readonly", props: { readOnly: true, value: "Confirmed project" } },
]} ]}
/> />
+1 -1
View File
@@ -37,7 +37,7 @@ function Specimen() {
<MenubarItem> <MenubarItem>
열기 <MenubarShortcut>⌘O</MenubarShortcut> 열기 <MenubarShortcut>⌘O</MenubarShortcut>
</MenubarItem> </MenubarItem>
<MenubarItem disabled>저장 (사용 불가)</MenubarItem> <MenubarItem disabled>저장</MenubarItem>
<MenubarSeparator /> <MenubarSeparator />
<MenubarSub> <MenubarSub>
<MenubarSubTrigger>내보내기</MenubarSubTrigger> <MenubarSubTrigger>내보내기</MenubarSubTrigger>
+1 -1
View File
@@ -81,7 +81,7 @@ type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): one Variants section, 2 equal columns x 2 rows on PC (iframe width // Representative-page layout (Storybook presentation only): one Variants section, 2 equal columns x 2 rows on PC (iframe width
// 1030px and up), 1 column below that. Each specimen sits in its own single-column StateBoard (min larger than any // 1030px and up), 1 column below that. Each specimen sits in its own single-column StateBoard (min larger than any
// column), so every title and divider has the width of its column. The Pagination root is `mx-auto flex w-full justify-center`; // column), so every title has the width of its column. The Pagination root is `mx-auto flex w-full justify-center`;
// `justify-start` on the cell left-aligns the button group in its column without stretching it. // `justify-start` on the cell left-aligns the button group in its column without stretching it.
const cellAlignLeft = "w-full [&_[data-slot=pagination]]:justify-start" const cellAlignLeft = "w-full [&_[data-slot=pagination]]:justify-start"
+4 -7
View File
@@ -75,15 +75,12 @@ const meta = { title: "Components/Overlay/Popover", component: Popover, paramete
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Keep the board's accessible headings and shared indent, with only the triggers visible in the four-column grid.
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider function SpecimenGrid({ states }: { states: BoardState[] }) {
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
return ( return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}> <div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4 [&_[data-overview-state]>header]:sr-only">
{states.map((state) => ( {states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} /> <StateBoard key={state.id} min={10000} states={[{ ...state, children: <div className="flex items-start">{state.children}</div> }]} />
))} ))}
</div> </div>
) )
+1 -1
View File
@@ -50,7 +50,7 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (240px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider // (240px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. // has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
const specimenColumns = { const specimenColumns = {
2: "min-[1000px]:grid-cols-2", 2: "min-[1000px]:grid-cols-2",
+118
View File
@@ -0,0 +1,118 @@
import { Label, RadioGroup, RadioGroupItem } from "@hm-design/ui"
import type { CSSProperties } from "react"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Radio Group specimens (Storybook only). Used by the Radio Group Stories (verification) and by the Selection Controls
// representative page ("선택 컨트롤"), which shows Checkbox, Radio Group and Switch under one sidebar item.
// Current HM Radio Group. ui-viz has no Radio spec, so it was reference only.
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility
// like `text-foreground` loses to it; overriding the Label's own token on the element works. Option labels use the default
// text color; a Label next to a disabled item (`peer-disabled`) keeps the secondary color. State titles are untouched.
const optionLabel = "[--hm-label-text:var(--foreground)] peer-disabled:[--hm-label-text:var(--hm-sem-fg-secondary)]"
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), shrinking to
// fit below that (`min` is the narrowest column). Each specimen sits in its own single-column StateBoard (min larger than
// any column), so StateBoard's auto-fill grid cannot leave an empty track. Full class names are listed so Tailwind sees them.
const specimenColumns = {
2: "min-[1000px]:grid-cols-2",
4: "min-[1000px]:grid-cols-4",
} as const
// `className` replaces the track (the Selection Controls page passes its shared 4-column grid); `spans` gives a specimen
// cell more than one track there.
function SpecimenGrid({ states, columns, min = 220, className, spans }: { states: BoardState[]; columns: keyof typeof specimenColumns; min?: number; className?: string; spans?: Record<string, string> }) {
return (
<div
className={className ?? `grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--sb-col-min)),1fr))] ${specimenColumns[columns]}`}
style={className ? undefined : ({ "--sb-col-min": `${min}px` } as CSSProperties)}
>
{states.map((state) =>
spans?.[state.id] ? (
<div key={state.id} className={`min-w-0 ${spans[state.id]}`}>
<StateBoard min={10000} states={[state]} />
</div>
) : (
<StateBoard key={state.id} min={10000} states={[state]} />
),
)}
</div>
)
}
export function Group({
prefix,
defaultValue = "a",
disabled,
disabledItem,
invalid,
orientation,
hoverItem,
focusItem,
}: {
prefix: string
defaultValue?: string
disabled?: boolean
disabledItem?: string
invalid?: boolean
orientation?: "horizontal" | "vertical"
// Pins :hover on one item with the Storybook-only data-sb-hover attribute (.storybook/pseudo-states.ts).
hoverItem?: string
// Pins :focus-visible (the focus ring) on one item with the Storybook-only data-sb-focus attribute (2026-11-13).
focusItem?: string
}) {
return (
// The group name is a readable Korean name; `prefix` stays an internal id prefix only (screen readers no longer read it).
<RadioGroup defaultValue={defaultValue} disabled={disabled} orientation={orientation} aria-label="옵션 선택">
{["a", "b", "c"].map((v) => (
<div key={v} className="flex items-center gap-2">
<RadioGroupItem value={v} id={`${prefix}-${v}`} disabled={disabledItem === v} aria-invalid={invalid ? true : undefined} data-sb-hover={hoverItem === v ? "" : undefined} data-sb-focus={focusItem === v ? "" : undefined} />
<Label htmlFor={`${prefix}-${v}`} className={optionLabel}>Option {v.toUpperCase()}</Label>
</div>
))}
</RadioGroup>
)
}
// The Radio Group sections of a representative page. `titlePrefix` names the component in each section title and `idPrefix`
// keeps the specimen ids (data-overview-state) unique when the sections share a page with other components. `gridClassName`
// gives every section of a shared page one column track (Selection Controls); without it the Radio Group page keeps its own.
// In the shared track the Horizontal group takes 2 tracks from 1000px and the whole row below that, so its 3 options stay on
// one line (the horizontal group wraps, flex-wrap, when its cell is narrower than the row).
export function RadioGroupSections({ titlePrefix, idPrefix = "", gridClassName }: { titlePrefix?: string; idPrefix?: string; gridClassName?: string }) {
const title = (name: string) => (titlePrefix ? `${titlePrefix} · ${name}` : name)
return (
<>
{/* Overview is merged into Variants: its Vertical example is the first Variants item. */}
<StorySection title={title("Variants")}>
<SpecimenGrid
className={gridClassName}
spans={gridClassName ? { [`${idPrefix}horizontal`]: "col-span-full min-[1000px]:col-span-2" } : undefined}
columns={2}
min={320}
states={[
{ id: `${idPrefix}vertical`, label: "Vertical", children: <Group prefix="v" defaultValue="b" /> },
{ id: `${idPrefix}horizontal`, label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> },
]}
/>
</StorySection>
<StorySection title={title("States")}>
<SpecimenGrid
className={gridClassName}
columns={4}
states={[
{ id: `${idPrefix}hover`, label: "Hover", children: <Group prefix="hv" defaultValue="" hoverItem="a" /> },
{ id: `${idPrefix}focus`, label: "Focus", children: <Group prefix="f" /> },
{ id: `${idPrefix}disabled-item`, label: "Disabled · 항목", children: <Group prefix="di" disabledItem="b" /> },
{ id: `${idPrefix}disabled-group`, label: "Disabled · 그룹", children: <Group prefix="dg" disabled /> },
]}
/>
</StorySection>
<StorySection title={title("Validation")}>
{/* Same 4-column track as States, so the Error example keeps the width of one States column. */}
<SpecimenGrid className={gridClassName} columns={4} states={[{ id: `${idPrefix}error`, label: "Error", children: <Group prefix="e" invalid /> }]} />
</StorySection>
</>
)
}
+10 -87
View File
@@ -1,66 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Label, RadioGroup, RadioGroupItem } from "@hm-design/ui" import { RadioGroup } from "@hm-design/ui"
import type { CSSProperties } from "react" import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { StateBoard, type BoardState } from "@/components/story-board" import { Group, RadioGroupSections } from "./radio-group-examples"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Current HM Radio Group. ui-viz has no Radio spec, so it was reference only. // Current HM Radio Group. The specimens live in ./radio-group-examples and are also shown on the Selection Controls
// representative page ("선택 컨트롤", Checkbox / Radio Group / Switch), the single sidebar item for the three. Every Story here
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility // keeps its ID and direct URL but is hidden from the sidebar (tag `verification`).
// like `text-foreground` loses to it; overriding the Label's own token on the element works. Option labels use the default
// text color; a Label next to a disabled item (`peer-disabled`) keeps the secondary color. State titles are untouched.
const optionLabel = "[--hm-label-text:var(--foreground)] peer-disabled:[--hm-label-text:var(--hm-sem-fg-secondary)]"
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), shrinking to
// fit below that (`min` is the narrowest column). Each specimen sits in its own single-column StateBoard (min larger than
// any column), so StateBoard's auto-fill grid cannot leave an empty track. Full class names are listed so Tailwind sees them.
const specimenColumns = {
2: "min-[1000px]:grid-cols-2",
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ states, columns, min = 220 }: { states: BoardState[]; columns: keyof typeof specimenColumns; min?: number }) {
return (
<div
className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--sb-col-min)),1fr))] ${specimenColumns[columns]}`}
style={{ "--sb-col-min": `${min}px` } as CSSProperties}
>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
function Group({
prefix,
defaultValue = "a",
disabled,
disabledItem,
invalid,
orientation,
hoverItem,
}: {
prefix: string
defaultValue?: string
disabled?: boolean
disabledItem?: string
invalid?: boolean
orientation?: "horizontal" | "vertical"
// Pins :hover on one item with the Storybook-only data-sb-hover attribute (.storybook/pseudo-states.ts).
hoverItem?: string
}) {
return (
<RadioGroup defaultValue={defaultValue} disabled={disabled} orientation={orientation} aria-label={prefix}>
{["a", "b", "c"].map((v) => (
<div key={v} className="flex items-center gap-2">
<RadioGroupItem value={v} id={`${prefix}-${v}`} disabled={disabledItem === v} aria-invalid={invalid ? true : undefined} data-sb-hover={hoverItem === v ? "" : undefined} />
<Label htmlFor={`${prefix}-${v}`} className={optionLabel}>Option {v.toUpperCase()}</Label>
</div>
))}
</RadioGroup>
)
}
const meta = { const meta = {
title: "Components/Form/Radio Group", title: "Components/Form/Radio Group",
@@ -78,38 +23,16 @@ const meta = {
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Selection
// Controls page shows the same sections.
export const All: Story = { export const All: Story = {
tags: ["verification"],
name: "Radio Group", name: "Radio Group",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Radio Group" /> <StoryPageHeader title="Radio Group" />
{/* Overview is merged into Variants: its Vertical example is the first Variants item. */} <RadioGroupSections />
<StorySection title="Variants">
<SpecimenGrid
columns={2}
min={320}
states={[
{ id: "vertical", label: "Vertical", children: <Group prefix="v" defaultValue="b" /> },
{ id: "horizontal", label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> },
]}
/>
</StorySection>
<StorySection title="States">
<SpecimenGrid
columns={4}
states={[
{ id: "hover", label: "Hover", children: <Group prefix="hv" defaultValue="" hoverItem="a" /> },
{ id: "focus", label: "Focus", children: <Group prefix="f" /> },
{ id: "disabled-item", label: "Disabled · 항목", children: <Group prefix="di" disabledItem="b" /> },
{ id: "disabled-group", label: "Disabled · 그룹", children: <Group prefix="dg" disabled /> },
]}
/>
</StorySection>
<StorySection title="Validation">
{/* Same 4-column track as States, so the Error example keeps the width of one States column. */}
<SpecimenGrid columns={4} states={[{ id: "error", label: "Error", children: <Group prefix="e" invalid /> }]} />
</StorySection>
</StoryPage> </StoryPage>
), ),
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
+1 -1
View File
@@ -100,7 +100,7 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), one column // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), one column
// below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so the title, divider and // below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so the title and
// specimen start line up and unused columns stay empty. The frames keep their own size (max 560px wide). // specimen start line up and unused columns stay empty. The frames keep their own size (max 560px wide).
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) { function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
return ( return (
+1 -1
View File
@@ -78,7 +78,7 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (300px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider // (300px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. // has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
const specimenColumns = { const specimenColumns = {
2: "min-[1000px]:grid-cols-2", 2: "min-[1000px]:grid-cols-2",
@@ -0,0 +1,23 @@
.sb-selection-sections > .sb-page-section:first-child {
margin-top: 0;
}
@container (width >= 720px) {
.sb-selection-controls .sb-selection-sections {
display: grid;
grid-template-columns: max-content repeat(3, minmax(max-content, 1fr));
column-gap: 24px;
}
.sb-selection-sections > .sb-page-section,
.sb-selection-sections > .sb-page-section > .sb-page-section-body,
.sb-selection-sections .sb-selection-grid {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.sb-selection-sections > .sb-page-section > .sb-page-section-header {
grid-column: 1 / -1;
}
}
@@ -0,0 +1,234 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import type { CSSProperties, ReactNode } from "react"
import { Checkbox, Switch } from "@hm-design/ui"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { Controlled, Option } from "./checkbox-examples"
import { Group } from "./radio-group-examples"
import { Option as SwitchOption } from "./switch-examples"
import "./selection-controls-story.css"
// Selection Controls ("선택 컨트롤", 2026-11-13): one representative page for the form on/off and choice controls Checkbox,
// Radio Group and Switch, which share the option Label, state and Disabled arrangement. Storybook presentation only: no new
// component, prop or API; the specimens are the components' public API with the ./<name>-examples parts (Option, Group,
// Controlled). The former Checkbox / Radio Group / Switch representative Stories keep their IDs and direct URLs but are
// hidden from the sidebar (tag `verification`), as are their own verification Stories.
// 2026-11-13 (user request, state comparison matrix): the six "<Component> · <Section>" sections are replaced by one state
// matrix (rows Default / Hover / Focus / Disabled / Validation, columns Checkbox / Radio Group / Switch) and one Variants
// section for what is not a common state (Checkbox Indeterminate, Radio Group Vertical / Horizontal; Switch has none).
const meta = { title: "Components/Form/Selection Controls", parameters: { layout: "padded" } } satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
// Labels follow the Button page matrix: H3 / row and column headers 14/20/500 secondary (StateBoard H3 style); the per-cell
// state names of the stacked layout and the sub-specimen captions one step below, 12/16/400 secondary.
const groupLabel = "m-0 text-sm font-medium text-muted-foreground"
const caption = "text-xs font-normal text-muted-foreground"
const states = [
["default", "Default"],
["hover", "Hover"],
["focus", "Focus"],
["disabled", "Disabled"],
["validation", "Validation"],
] as const
type StateKey = (typeof states)[number][0]
// Two specimens of one cell side by side (Unchecked / Checked, Off / On, 항목 / 그룹); they wrap only in the stacked layout.
function Pair({ children }: { children: ReactNode }) {
return <div className="flex flex-wrap items-start gap-x-6 gap-y-3">{children}</div>
}
// A specimen with its own small caption (Radio Group Disabled 항목 / 그룹, the Variants).
function Captioned({ id, label, children }: { id?: string; label: string; children: ReactNode }) {
return (
<div data-overview-state={id} role="group" aria-label={label} className="grid content-start gap-1">
<span className={caption} aria-hidden>
{label}
</span>
{children}
</div>
)
}
// Only states each component implements; Switch has no Validation state, so its cell shows "—".
// Hover pins :hover with the Storybook-only data-sb-hover attribute (.storybook/pseudo-states.ts). Radio Group ids use the
// "sc-" prefix so they never collide with another Group on the page.
const columns: { id: string; label: string; cells: Partial<Record<StateKey, ReactNode>> }[] = [
{
id: "checkbox",
label: "Checkbox",
cells: {
default: (
<Pair>
<Option label="Unchecked">{(id) => <Checkbox id={id} />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} defaultChecked />}</Option>
</Pair>
),
hover: (
<Pair>
<Option label="Unchecked">{(id) => <Checkbox id={id} data-sb-hover="" />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} defaultChecked data-sb-hover="" />}</Option>
</Pair>
),
focus: <Option label="Option">{(id) => <Checkbox id={id} />}</Option>,
disabled: (
<Pair>
<Option label="Unchecked">{(id) => <Checkbox id={id} disabled />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option>
</Pair>
),
validation: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option>,
},
},
{
id: "radio-group",
label: "Radio Group",
cells: {
default: <Group prefix="sc-d" defaultValue="b" />,
hover: <Group prefix="sc-hv" defaultValue="" hoverItem="a" />,
// 2026-11-13: the ring is pinned on Option A (data-sb-focus); only the Checkbox specimen holds the page focus.
focus: <Group prefix="sc-f" focusItem="a" />,
disabled: (
<Pair>
<Captioned id="radio-group-disabled-item" label="항목">
<Group prefix="sc-di" disabledItem="b" />
</Captioned>
<Captioned id="radio-group-disabled-group" label="그룹">
<Group prefix="sc-dg" disabled />
</Captioned>
</Pair>
),
// 2026-11-13 (user request): no option selected, so the Error row shows no green selected mark.
validation: <Group prefix="sc-e" defaultValue="" invalid />,
},
},
{
id: "switch",
label: "Switch",
cells: {
default: (
<Pair>
<SwitchOption label="Off">{(id) => <Switch id={id} />}</SwitchOption>
<SwitchOption label="On">{(id) => <Switch id={id} defaultChecked />}</SwitchOption>
</Pair>
),
hover: (
<Pair>
<SwitchOption label="Off">{(id) => <Switch id={id} data-sb-hover="" />}</SwitchOption>
<SwitchOption label="On">{(id) => <Switch id={id} defaultChecked data-sb-hover="" />}</SwitchOption>
</Pair>
),
focus: <SwitchOption label="Option">{(id) => <Switch id={id} data-sb-focus="" />}</SwitchOption>,
disabled: (
<Pair>
<SwitchOption label="Off">{(id) => <Switch id={id} disabled />}</SwitchOption>
<SwitchOption label="On">{(id) => <Switch id={id} disabled defaultChecked />}</SwitchOption>
</Pair>
),
},
},
]
// One DOM for both layouts, rendered once in component order (Checkbox → Radio Group → Switch, each Default → Validation),
// the stacked layout's reading order. In the matrix each component block becomes display: contents,
// so its header and cells use the same tracks as Variants.
const matrixPosition = "@min-[720px]:[grid-row:var(--sc-row)] @min-[720px]:[grid-column:var(--sc-col)]"
const place = (row: number, col: number) => ({ "--sc-row": row, "--sc-col": col }) as CSSProperties
function StateMatrix() {
return (
<div className="sb-selection-grid grid items-start gap-6">
{/* Row headers: the matrix only; in the stacked layout each cell names its own state. */}
{states.map(([key, label], r) => (
<div key={key} aria-hidden className={`hidden ${groupLabel} @min-[720px]:block ${matrixPosition}`} style={place(r + 2, 1)}>
{label}
</div>
))}
{columns.map((column, c) => (
<section key={column.id} aria-label={column.label} className="grid min-w-0 content-start gap-6 @min-[720px]:contents">
<h3 className={`${groupLabel} ${matrixPosition}`} style={place(1, c + 2)}>
{column.label}
</h3>
{states.map(([key, label], r) => {
const content = column.cells[key]
if (!content) {
return (
<div key={key} className={`hidden @min-[720px]:block ${matrixPosition}`} style={place(r + 2, c + 2)}>
<span aria-hidden className="text-sm text-muted-foreground">—</span>
<span className="sr-only">{`${column.label} ${label}: 해당 상태 없음`}</span>
</div>
)
}
return (
<div
key={key}
data-overview-state={`${column.id}-${key}`}
role="group"
aria-label={`${column.label} ${label}${key === "validation" ? " · Error" : ""}`}
className={`grid min-w-0 content-start gap-1 ${matrixPosition}`}
style={place(r + 2, c + 2)}
>
<span aria-hidden className={`${caption} @min-[720px]:hidden`}>
{label}
</span>
{content}
</div>
)
})}
</section>
))}
</div>
)
}
function Variants() {
return (
<div className="sb-selection-grid grid items-start gap-6">
<section aria-label="Checkbox" className="grid min-w-0 content-start gap-2 @min-[720px]:col-start-2">
<h3 className={groupLabel}>Checkbox</h3>
<Captioned id="checkbox-indeterminate" label="Indeterminate">
<Controlled />
</Captioned>
</section>
<section aria-label="Radio Group" className="grid min-w-0 content-start gap-2 @min-[720px]:col-span-2 @min-[720px]:col-start-3">
<h3 className={groupLabel}>Radio Group</h3>
<Pair>
<Captioned id="radio-group-vertical" label="Vertical">
<Group prefix="sc-v" defaultValue="b" />
</Captioned>
<Captioned id="radio-group-horizontal" label="Horizontal">
<Group prefix="sc-h" orientation="horizontal" />
</Captioned>
</Pair>
</section>
</div>
)
}
// Named after the group, so Storybook shows it as a single 1-depth item ("선택 컨트롤").
export const All: Story = {
name: "Selection Controls",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Selection Controls" />
<div className="sb-selection-controls @container [&_[data-sb-focus]]:ring-3 [&_[data-sb-focus]]:ring-ring/50">
<div className="sb-selection-sections">
<StorySection id="states" title="Selection Controls">
<StateMatrix />
</StorySection>
<StorySection id="variants" title="Variants">
<Variants />
</StorySection>
</div>
</div>
</StoryPage>
),
play: async ({ canvasElement }) => {
// Only one element can hold focus, so the page focuses the Checkbox Focus specimen. The Radio Group and Switch Focus
// specimens pin the same ring utilities with data-sb-focus, including in the optimized Storybook build.
// preventScroll: the page opens at its top, not at the focused specimen.
canvasElement.querySelector<HTMLButtonElement>('[data-overview-state="checkbox-focus"] [role="checkbox"]')?.focus({ preventScroll: true })
},
}
+1 -1
View File
@@ -21,7 +21,7 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (240px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider // (240px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. // has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
const specimenColumns = { const specimenColumns = {
2: "min-[1000px]:grid-cols-2", 2: "min-[1000px]:grid-cols-2",
+8 -7
View File
@@ -4,6 +4,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button, Input, Sheet, SheetBody, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@hm-design/ui" import { Button, Input, Sheet, SheetBody, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { DrawerSections } from "./drawer-examples"
// Sheet is a modal Radix overlay (Portal), so the representative Story is a row of triggers that open the real Sheet for each // Sheet is a modal Radix overlay (Portal), so the representative Story is a row of triggers that open the real Sheet for each
// side and content case. The same cases start open in the verification Stories. Radix owns focus trap, Escape and focus return. // side and content case. The same cases start open in the verification Stories. Radix owns focus trap, Escape and focus return.
@@ -94,21 +95,20 @@ const meta = { title: "Components/Overlay/Sheet", component: Sheet, parameters:
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Keep the board's accessible headings and shared indent, with only the triggers visible in the four-column grid.
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider function SpecimenGrid({ states }: { states: BoardState[] }) {
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
return ( return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}> <div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4 [&_[data-overview-state]>header]:sr-only">
{states.map((state) => ( {states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} /> <StateBoard key={state.id} min={10000} states={[{ ...state, children: <div className="flex items-start">{state.children}</div> }]} />
))} ))}
</div> </div>
) )
} }
// Representative Story: the common Story page. Overview is merged into Variants (Right is its first item). // Representative Story: the common Story page. Overview is merged into Variants (Right is its first item).
// 2026-11-13 (user request): it also shows the Drawer section from ./drawer-examples, so "시트·드로어" is the one sidebar item
// for both edge panels; the Drawer Stories keep their IDs and direct URLs but are hidden from the sidebar.
export const All: Story = { export const All: Story = {
name: "Sheet", name: "Sheet",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
@@ -133,6 +133,7 @@ export const All: Story = {
]} ]}
/> />
</StorySection> </StorySection>
<DrawerSections title="Drawer" idPrefix="drawer-" hideSpecimenLabels />
</StoryPage> </StoryPage>
), ),
} }
+1 -1
View File
@@ -54,7 +54,7 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (240px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider // (240px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. // has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
const specimenColumns = { const specimenColumns = {
2: "min-[1000px]:grid-cols-2", 2: "min-[1000px]:grid-cols-2",
+57
View File
@@ -0,0 +1,57 @@
.sb-slider-layout .sb-page-section:first-child {
margin-top: 0;
}
.sb-slider-layout .sb-page-section-body {
display: grid;
align-items: start;
gap: 24px;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr));
padding-inline-start: var(--sb-page-indent);
}
@media (min-width: 1000px) {
.sb-slider-layout {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: max-content max-content 1fr;
column-gap: 24px;
padding-inline-start: var(--sb-page-indent);
}
.sb-slider-layout .sb-page-section-header {
margin-inline-start: calc(-1 * var(--sb-page-indent));
}
/* The vertical specimen spans the last two rows so the horizontal examples set the start of States. */
.sb-slider-layout #slider-variants {
display: grid;
grid-area: 1 / 1 / 4 / -1;
grid-template: subgrid / subgrid;
}
.sb-slider-layout #slider-variants > .sb-page-section-header {
grid-column: 1 / -1;
}
.sb-slider-layout #slider-variants > .sb-page-section-body {
grid-area: 2 / 1 / 4 / -1;
grid-template: subgrid / subgrid;
row-gap: 0;
padding-inline-start: 0;
}
.sb-slider-layout [data-overview-state="vertical"] {
grid-area: 1 / 4 / 3 / 5;
}
.sb-slider-layout #slider-states {
grid-area: 3 / 1 / 4 / 2;
align-self: start;
}
.sb-slider-layout #slider-states > .sb-page-section-body {
grid-template-columns: minmax(0, 1fr);
padding-inline-start: 0;
}
}
+27 -41
View File
@@ -1,38 +1,40 @@
import { useState, type ReactNode } from "react" import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Slider } from "@hm-design/ui" import { Slider } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import "./slider-story.css"
// The representative Story shows each Slider mode with real, draggable controls; the same specimens also exist as Stories tagged // The representative Story shows each Slider mode with real, draggable controls; the same specimens also exist as Stories tagged
// `verification` (hidden from the sidebar). The value label is the Story's composition (Slider has no built-in label). // `verification` (hidden from the sidebar). The value label is the Story's composition (Slider has no built-in label).
// Names (`aria-label`) are provided by the consumer. // Names (`aria-label`) are provided by the consumer.
function Labeled({ label, children, valueText }: { label: string; children: ReactNode; valueText: string }) { function Labeled({ id, label, children, valueText }: { id?: string; label: string; children: ReactNode; valueText?: string }) {
const Example = id ? "section" : "div"
const Label = id ? "h3" : "span"
return ( return (
<div className="grid gap-3"> <Example className="grid min-w-0 gap-3" data-overview-state={id} aria-label={id ? label : undefined}>
<div className="flex justify-between text-sm"> <div className="flex justify-between gap-2 text-sm">
<span className="font-medium text-foreground">{label}</span> <Label className={id ? "font-medium text-muted-foreground" : "font-medium text-foreground"}>{label}</Label>
<span className="text-muted-foreground" data-testid="value">{valueText}</span> {valueText !== undefined && <span className="text-muted-foreground" data-testid="value">{valueText}</span>}
</div> </div>
{children} {children}
</div> </Example>
) )
} }
function Single() { function Single({ example = false }: { example?: boolean }) {
const [value, setValue] = useState([40]) const [value, setValue] = useState([40])
return ( return (
<Labeled label="볼륨" valueText={`${value[0]}`}> <Labeled id={example ? "single" : undefined} label="볼륨" valueText={`${value[0]}`}>
<Slider aria-label="볼륨" value={value} onValueChange={setValue} max={100} step={1} /> <Slider aria-label="볼륨" value={value} onValueChange={setValue} max={100} step={1} />
</Labeled> </Labeled>
) )
} }
function Range() { function Range({ example = false }: { example?: boolean }) {
const [value, setValue] = useState([25, 75]) const [value, setValue] = useState([25, 75])
return ( return (
<Labeled label="범위" valueText={`${value[0]} – ${value[1]}`}> <Labeled id={example ? "range" : undefined} label="범위" valueText={`${value[0]} – ${value[1]}`}>
<Slider aria-label="범위" value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} /> <Slider aria-label="범위" value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} />
</Labeled> </Labeled>
) )
@@ -41,7 +43,7 @@ function Range() {
function Step() { function Step() {
const [value, setValue] = useState([50]) const [value, setValue] = useState([50])
return ( return (
<Labeled label="단계 (step 25)" valueText={`${value[0]}`}> <Labeled id="step" label="단계 (25)" valueText={`${value[0]}`}>
<Slider aria-label="단계" value={value} onValueChange={setValue} max={100} step={25} /> <Slider aria-label="단계" value={value} onValueChange={setValue} max={100} step={25} />
</Labeled> </Labeled>
) )
@@ -59,47 +61,31 @@ function Vertical() {
) )
} }
const sections = [
{ id: "single", label: "Single", node: <Single /> },
{ id: "range", label: "Range", node: <Range /> },
{ id: "step", label: "Step", node: <Step /> },
{ id: "disabled", label: "Disabled", node: <Disabled /> },
{ id: "vertical", label: "Vertical", node: <Vertical /> },
]
const meta = { title: "Components/Form/Slider", component: Slider, parameters: { layout: "padded" } } satisfies Meta<typeof Slider> const meta = { title: "Components/Form/Slider", component: Slider, parameters: { layout: "padded" } } satisfies Meta<typeof Slider>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): 4 equal columns on PC (iframe width 1000px and up), shrinking to
// fit below that (200px minimum per column). Each specimen sits in its own single-column StateBoard (min larger than any
// column), so StateBoard's auto-fill grid cannot leave an empty or stretched track. A section with fewer than 4 specimens
// keeps the same column width and leaves the remaining tracks empty.
function SpecimenGrid({ ids }: { ids: string[] }) {
return (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4">
{ids.map((id) => {
const s = sections.find((section) => section.id === id)!
return <StateBoard key={id} min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
})}
</div>
)
}
// Representative Story: the common Story page; the sections group the same specimens.
// Overview is merged into Variants (Single is its first item). `Vertical` centers its Slider in its column.
export const All: Story = { export const All: Story = {
name: "Slider", name: "Slider",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Slider" /> <StoryPageHeader title="Slider" />
<StorySection title="Variants"> <div className="sb-slider-layout">
<SpecimenGrid ids={["single", "range", "step", "vertical"]} /> <StorySection id="slider-variants" title="Variants">
<Single example />
<Range example />
<Step />
<Labeled id="vertical" label="세로 슬라이더">
<Vertical />
</Labeled>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection id="slider-states" title="States">
<SpecimenGrid ids={["disabled"]} /> <Labeled id="disabled" label="비활성">
<Disabled />
</Labeled>
</StorySection> </StorySection>
</div>
</StoryPage> </StoryPage>
), ),
} }
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more