diff --git a/.storybook/manager.css b/.storybook/manager.css index 3682277..b1c3458 100644 --- a/.storybook/manager.css +++ b/.storybook/manager.css @@ -16,7 +16,8 @@ --overlay-black-30: rgba(0, 0, 0, .3); } /* 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-family: 'Hanmac Manager Noto Sans KR'; src: url('/fonts/NotoSansKR-variable.ttf') format('truetype'); @@ -36,7 +37,7 @@ } #storybook-sidebar-region { - --hm-manager-join: 18px; + --hm-manager-join: 12px; color: var(--color-fg-on-green-primary); background: linear-gradient(90deg, var(--hm-green-default) 0%, var(--hm-green-light-1) 30%, @@ -46,7 +47,7 @@ font-family: var(--hm-manager-font); } #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 item must reach the content edge without a scroll thumb crossing its curves. @@ -61,13 +62,17 @@ background: var(--hm-fg-on-green-secondary); } #storybook-sidebar-region .sidebar-header { - margin: 0 20px 0px 0; + margin: 0 14px 0px 0; padding: 0; color: var(--color-fg-on-green-primary); } /* 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 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); font: 700 16px/30px var(--hm-manager-font); white-space: normal; @@ -113,27 +118,18 @@ #storybook-explorer-tree .sidebar-subheading { position: relative; margin: 0; - padding: 0 20px 0 0; - min-height: 48px; + padding: 0 14px 0 0; + min-height: 40px; color: var(--hm-fg-on-green-secondary); } #storybook-explorer-tree .sidebar-subheading[data-hm-section]:not([data-hm-section='0']) { - margin-top: 24px; -} -#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); + margin-top: 0; } +/* 2026-11-13: the divider above each group was removed. */ #storybook-explorer-tree .sidebar-subheading > button { - min-height: 48px; + min-height: 40px; color: inherit; - font: 700 14px/28px var(--hm-manager-font); + font: 700 14px/24px var(--hm-manager-font); letter-spacing: 0; text-transform: none; } @@ -141,7 +137,7 @@ position: relative; margin: 0; padding: 0; - min-height: 48px; + min-height: 40px; overflow: visible; border: 0; border-radius: 0; @@ -155,51 +151,51 @@ box-shadow: inset 1px 0 var(--hm-green-light-2); } #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); } #storybook-explorer-tree .sidebar-item[data-hm-group-end='true'] { - border-radius: 0 0 0 8px; - padding-bottom: 8px; + border-radius: 0 0 0 6px; + padding-bottom: 6px; box-shadow: inset 1px -1px var(--hm-green-light-2); } #storybook-explorer-tree .sidebar-item > :is(a[id], button[id]:not([data-testid])) { position: relative; display: flex; align-items: center; - gap: 12px; - min-height: 48px; + gap: 8px; + min-height: 40px; height: auto; width: 100%; margin: 0; - padding: 10px 32px 10px 18px; + padding: 8px 14px 8px 10px; border: 0; border-radius: 4px 0 0 4px; background: transparent; color: inherit; - font: 700 14px/28px var(--hm-manager-font); + font: 700 14px/24px var(--hm-manager-font); text-align: left; text-decoration: none; white-space: normal; overflow: visible; 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']) { - 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])) { - min-height: 42px; - padding-block: 7px; + min-height: 36px; + padding-block: 6px; } #storybook-explorer-tree .sidebar-item[data-hm-depth='2'] > :is(a[id], button[id]:not([data-testid])) { - margin-left: 32px; - width: calc(100% - 32px); + margin-left: 26px; + 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='4'] > :is(a[id], button[id]:not([data-testid])) { - margin-left: 44px; - width: calc(100% - 44px); + margin-left: 36px; + width: calc(100% - 36px); } /* Menu item labels and section heading labels (.sidebar-subheading) are both 14px / 700. */ #storybook-explorer-tree .sidebar-item .hm-manager-label { @@ -246,9 +242,9 @@ display: none; } /* 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)) { - padding-left: 32px; + padding-left: 26px; } #storybook-explorer-tree .sidebar-item[data-hm-current-parent='true'], #storybook-explorer-tree .sidebar-subheading[data-hm-current-parent='true'] { @@ -261,7 +257,7 @@ z-index: 1; background: var(--color-bg-content); 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. 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)); } #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. Hide only these controls; the Story link and native group toggles stay intact. */ diff --git a/.storybook/manager.js b/.storybook/manager.js index 0affe07..6df1296 100644 --- a/.storybook/manager.js +++ b/.storybook/manager.js @@ -29,6 +29,9 @@ toolbarStyle.textContent = ` document.head.appendChild(toolbarStyle) // 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 = { foundation: '기본 요소', components: '컴포넌트', @@ -43,6 +46,7 @@ const koreanLabels = { 'components-form-input': '입력창', 'components-form-textarea': '텍스트 영역', 'components-form-select': '선택 목록', + 'components-form-selection-controls': '선택 컨트롤', 'components-form-checkbox': '체크박스', 'components-form-radio-group': '라디오 버튼', 'components-form-switch': '스위치', @@ -55,12 +59,12 @@ const koreanLabels = { 'components-navigation-menubar': '메뉴바', 'components-overlay-dialog': '대화상자', 'components-overlay-alert-dialog': '확인·경고 대화상자', - 'components-overlay-sheet': '시트 패널', + 'components-overlay-sheet': '시트·드로어', 'components-overlay-drawer': '드로어', - 'components-overlay-dropdown-menu': '드롭다운 메뉴', + 'components-overlay-dropdown-menu': '팝업 메뉴', 'components-overlay-context-menu': '컨텍스트 메뉴', 'components-overlay-popover': '팝오버', - 'components-overlay-tooltip': '툴팁', + 'components-overlay-tooltip': '툴팁·호버 카드', 'components-overlay-hover-card': '호버 카드', 'components-data-display-card': '카드', 'components-data-display-table': '테이블', @@ -72,7 +76,7 @@ const koreanLabels = { 'components-data-display-aspect-ratio': '화면 비율', 'components-data-display-carousel': '캐러셀', 'components-data-display-scroll-area': '스크롤 영역', - 'components-disclosure-accordion': '아코디언', + 'components-disclosure-accordion': '접기·펼치기', 'components-disclosure-collapsible': '접기·펼치기', 'components-disclosure-resizable': '크기 조절', 'components-search-date-command': '명령어 검색', @@ -86,9 +90,9 @@ const koreanLabels = { 'patterns-data-data-table': '데이터 테이블', 'patterns-data-filter-bar': '필터 바', 'patterns-layout': '화면 구성', - 'patterns-layout-gnb': '전체 메뉴 (GNB)', + 'patterns-layout-gnb': '상단 메뉴', 'patterns-layout-dashboard': '대시보드', - 'patterns-layout-lnb': '좌측 메뉴 (LNB)', + 'patterns-layout-lnb': '좌측 메뉴', 'patterns-layout-dashboard-layout': '대시보드', 'patterns-layout-page-header': '페이지 헤더', chart: '차트', diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 882840b..f8efcd6 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -29,7 +29,8 @@ const preview: Preview = { "Components", [ "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", ["Tabs", "Breadcrumb", "Pagination", "Navigation Menu", "Menubar"], "Overlay", @@ -48,9 +49,9 @@ const preview: Preview = { "Chart", ["Bar Chart", "Line Chart", "Donut Chart", "Pie Chart", "Radar Chart", "Area Chart", "Radial Chart"], "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). diff --git a/.storybook/pseudo-states.ts b/.storybook/pseudo-states.ts index 35f7d9f..3376e3e 100644 --- a/.storybook/pseudo-states.ts +++ b/.storybook/pseudo-states.ts @@ -1,19 +1,28 @@ -// Pins :hover and :active 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 +// Pins :hover, :active and :focus-visible visuals for state Stories (Storybook only, never loaded by the app). +// 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. +// 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 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() function mirror(list: CSSStyleSheet | CSSGroupingRule) { for (let i = list.cssRules.length - 1; i >= 0; i--) { const rule = list.cssRules[i] if (rule instanceof CSSStyleRule) { - if (/:hover\b|:active\b/.test(rule.selectorText)) { - list.insertRule( - rule.cssText.replace(/:hover\b/g, `[${HOVER}]`).replace(/:active\b/g, `[${ACTIVE}]`), - i + 1, - ) + if (PSEUDO.test(rule.selectorText)) { + // `base` keeps :focus-visible (the pre-2026-11-13 clone), so real focus + data-sb-hover / data-sb-active still matches + // a compound rule such as :focus-visible:active (Slider Focus + Drag). `full` also swaps :focus-visible. + 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) { mirror(rule as CSSGroupingRule) diff --git a/.storybook/qa/STORY-INVENTORY.md b/.storybook/qa/STORY-INVENTORY.md index 381695b..39c399f 100644 --- a/.storybook/qa/STORY-INVENTORY.md +++ b/.storybook/qa/STORY-INVENTORY.md @@ -6,16 +6,16 @@ This is a classification record. No Story IDs, exports or titles are renamed. HI ## Summary -- PRIMARY: 82 -- VERIFICATION: 256 +- PRIMARY: 73 +- VERIFICATION: 266 - UTILITY: 49 - LEGACY / DUPLICATE: 0 -- KEEP: 82 -- HIDE: 305 +- KEEP: 73 +- HIDE: 315 - MERGE_CANDIDATE: 33 - DELETE_CANDIDATE: 0 -- Story files: 89 -- Stories: 387 +- Story files: 90 +- Stories: 388 - Hierarchy: Foundation → Components → Patterns → Chart → SW → 조직도 → 노선도 → Verification → Theme ## Full inventory @@ -29,7 +29,7 @@ Source: `src/center/center-facility.stories.tsx`; meta.tags: [] - Intersection — 교차로; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. - Junction — 분기점; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. - Interchange — 나들목; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. -- Roadside — 휴게소 · 졸음쉼터; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. +- Roadside — 연계 노선; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. - Linked — 연계 시설물; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. - TunnelPlayground — 직접 테스트 (터널); tags: ["verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. @@ -114,7 +114,7 @@ Source: `src/components/accordion.stories.tsx`; meta.tags: [] Source: `src/components/alert-dialog.stories.tsx`; meta.tags: [] -- All — Alert Dialog; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. +- All — Alert Dialog; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - ConfirmationOpen — Confirmation; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - DestructiveOpen — Destructive; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. @@ -179,7 +179,7 @@ Source: `src/components/button.stories.tsx`; meta.tags: [] Source: `src/components/calendar.stories.tsx`; meta.tags: [] -- All — Calendar; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. +- All — Calendar; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - SingleSelection — Single; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - DisabledSelection — Disabled Dates; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - RangeSelection — Range; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. @@ -207,7 +207,7 @@ Source: `src/components/carousel.stories.tsx`; meta.tags: [] Source: `src/components/checkbox.stories.tsx`; meta.tags: [] -- All — Checkbox; tags: ["play-fn"]; PRIMARY; KEEP. Representative story or distinct reference page. +- All — Checkbox; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Unchecked — Unchecked; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Checked — Checked; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Indeterminate — Indeterminate; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. @@ -220,7 +220,7 @@ Source: `src/components/checkbox.stories.tsx`; meta.tags: [] Source: `src/components/collapsible.stories.tsx`; meta.tags: [] -- All — Collapsible; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. +- All — Collapsible; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Closed — Closed; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Open — Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed. @@ -249,7 +249,7 @@ Source: `src/components/command.stories.tsx`; meta.tags: [] Source: `src/components/context-menu.stories.tsx`; meta.tags: [] -- All — Context Menu; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. +- All — Context Menu; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Open — Open; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. ### Components/Search & Date/Date Picker @@ -277,7 +277,7 @@ Source: `src/components/dialog.stories.tsx`; meta.tags: [] Source: `src/components/drawer.stories.tsx`; meta.tags: [] -- All — Drawer; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. +- All — Drawer; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Open — Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Form — Form; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - LongContent — Long Content; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. @@ -293,7 +293,7 @@ Source: `src/components/dropdown-menu.stories.tsx`; meta.tags: [] Source: `src/components/hover-card.stories.tsx`; meta.tags: [] -- All — Hover Card; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. +- All — Hover Card; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Open — Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. ### Components/Form/Input @@ -374,7 +374,7 @@ Source: `src/components/publisher-regression.stories.tsx`; meta.tags: ["verifica Source: `src/components/radio-group.stories.tsx`; meta.tags: [] -- All — Radio Group; tags: ["play-fn"]; PRIMARY; KEEP. Representative story or distinct reference page. +- All — Radio Group; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Vertical — Vertical; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed. - Horizontal — Horizontal; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed. - Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed. @@ -420,6 +420,12 @@ Source: `src/components/select.stories.tsx`; meta.tags: [] - Scroll — Scroll; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Playground — Playground; tags: ["verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. +### Components/Form/Selection Controls + +Source: `src/components/selection-controls.stories.tsx`; meta.tags: [] + +- All — Selection Controls; tags: ["play-fn"]; PRIMARY; KEEP. Representative story or distinct reference page. + ### Components/Data & Display/Separator Source: `src/components/separator.stories.tsx`; meta.tags: [] @@ -468,7 +474,7 @@ Source: `src/components/sonner.stories.tsx`; meta.tags: [] Source: `src/components/switch.stories.tsx`; meta.tags: [] -- All — Switch; tags: ["play-fn"]; PRIMARY; KEEP. Representative story or distinct reference page. +- All — Switch; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Off — Off; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - On — On; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed. @@ -532,7 +538,7 @@ Source: `src/components/toggle-group.stories.tsx`; meta.tags: ["internal"] Source: `src/components/toggle.stories.tsx`; meta.tags: [] -- All — Toggle; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. +- All — Toggle; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - States — States; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed. - IconOnly — Icon Only; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - DisabledToggle — Disabled; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. @@ -734,7 +740,7 @@ Source: `src/patterns/page-header.stories.tsx`; meta.tags: ["verification"] Source: `src/sw/sw-footer.stories.tsx`; meta.tags: [] -- FullFooter — SW 하단 명령어바; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. +- FullFooter — SW 명령어바; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. - ToolGroups — 도구 그룹별 표시; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - FigmaVariants — Figma 디자인별 Variant (35 노드); tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - CustomContent — 도구 및 메뉴 변경 예제; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. diff --git a/.storybook/qa/story-inventory.json b/.storybook/qa/story-inventory.json index 3601e38..9b11321 100644 --- a/.storybook/qa/story-inventory.json +++ b/.storybook/qa/story-inventory.json @@ -1,16 +1,16 @@ { "summary": { - "storyFiles": 89, - "stories": 387, + "storyFiles": 90, + "stories": 388, "categories": { - "PRIMARY": 82, - "VERIFICATION": 256, + "PRIMARY": 73, + "VERIFICATION": 266, "UTILITY": 49, "LEGACY / DUPLICATE": 0 }, "decisions": { - "KEEP": 82, - "HIDE": 305, + "KEEP": 73, + "HIDE": 315, "MERGE_CANDIDATE": 33, "DELETE_CANDIDATE": 0 }, @@ -90,7 +90,7 @@ { "id": "center-graphic-facility--roadside", "exportName": "Roadside", - "name": "휴게소 · 졸음쉼터", + "name": "연계 노선", "storyTags": [], "effectiveTags": [], "category": "PRIMARY", @@ -430,12 +430,16 @@ "id": "components-overlay-alert-dialog--all", "exportName": "All", "name": "Alert Dialog", - "storyTags": [], - "effectiveTags": [], - "category": "PRIMARY", - "decision": "KEEP", + "storyTags": [ + "verification" + ], + "effectiveTags": [ + "verification" + ], + "category": "VERIFICATION", + "decision": "HIDE", "candidate": null, - "reason": "Representative story or distinct reference page." + "reason": "Keep direct URL and tag-filter access; excluded from default sidebar." }, { "id": "components-overlay-alert-dialog--confirmation-open", @@ -901,12 +905,16 @@ "id": "components-search-date-calendar--all", "exportName": "All", "name": "Calendar", - "storyTags": [], - "effectiveTags": [], - "category": "PRIMARY", - "decision": "KEEP", + "storyTags": [ + "verification" + ], + "effectiveTags": [ + "verification" + ], + "category": "VERIFICATION", + "decision": "HIDE", "candidate": null, - "reason": "Representative story or distinct reference page." + "reason": "Keep direct URL and tag-filter access; excluded from default sidebar." }, { "id": "components-search-date-calendar--single-selection", @@ -1106,15 +1114,17 @@ "exportName": "All", "name": "Checkbox", "storyTags": [ + "verification", "play-fn" ], "effectiveTags": [ + "verification", "play-fn" ], - "category": "PRIMARY", - "decision": "KEEP", + "category": "VERIFICATION", + "decision": "HIDE", "candidate": null, - "reason": "Representative story or distinct reference page." + "reason": "Keep direct URL and tag-filter access; excluded from default sidebar." }, { "id": "components-form-checkbox--unchecked", @@ -1232,12 +1242,16 @@ "id": "components-disclosure-collapsible--all", "exportName": "All", "name": "Collapsible", - "storyTags": [], - "effectiveTags": [], - "category": "PRIMARY", - "decision": "KEEP", + "storyTags": [ + "verification" + ], + "effectiveTags": [ + "verification" + ], + "category": "VERIFICATION", + "decision": "HIDE", "candidate": null, - "reason": "Representative story or distinct reference page." + "reason": "Keep direct URL and tag-filter access; excluded from default sidebar." }, { "id": "components-disclosure-collapsible--closed", @@ -1451,12 +1465,16 @@ "id": "components-overlay-context-menu--all", "exportName": "All", "name": "Context Menu", - "storyTags": [], - "effectiveTags": [], - "category": "PRIMARY", - "decision": "KEEP", + "storyTags": [ + "verification" + ], + "effectiveTags": [ + "verification" + ], + "category": "VERIFICATION", + "decision": "HIDE", "candidate": null, - "reason": "Representative story or distinct reference page." + "reason": "Keep direct URL and tag-filter access; excluded from default sidebar." }, { "id": "components-overlay-context-menu--open", @@ -1657,12 +1675,16 @@ "id": "components-overlay-drawer--all", "exportName": "All", "name": "Drawer", - "storyTags": [], - "effectiveTags": [], - "category": "PRIMARY", - "decision": "KEEP", + "storyTags": [ + "verification" + ], + "effectiveTags": [ + "verification" + ], + "category": "VERIFICATION", + "decision": "HIDE", "candidate": null, - "reason": "Representative story or distinct reference page." + "reason": "Keep direct URL and tag-filter access; excluded from default sidebar." }, { "id": "components-overlay-drawer--open", @@ -1753,12 +1775,16 @@ "id": "components-overlay-hover-card--all", "exportName": "All", "name": "Hover Card", - "storyTags": [], - "effectiveTags": [], - "category": "PRIMARY", - "decision": "KEEP", + "storyTags": [ + "verification" + ], + "effectiveTags": [ + "verification" + ], + "category": "VERIFICATION", + "decision": "HIDE", "candidate": null, - "reason": "Representative story or distinct reference page." + "reason": "Keep direct URL and tag-filter access; excluded from default sidebar." }, { "id": "components-overlay-hover-card--open", @@ -2346,15 +2372,17 @@ "exportName": "All", "name": "Radio Group", "storyTags": [ + "verification", "play-fn" ], "effectiveTags": [ + "verification", "play-fn" ], - "category": "PRIMARY", - "decision": "KEEP", + "category": "VERIFICATION", + "decision": "HIDE", "candidate": null, - "reason": "Representative story or distinct reference page." + "reason": "Keep direct URL and tag-filter access; excluded from default sidebar." }, { "id": "components-form-radio-group--vertical", @@ -2802,6 +2830,28 @@ } ] }, + { + "file": "src/components/selection-controls.stories.tsx", + "title": "Components/Form/Selection Controls", + "metaTags": [], + "stories": [ + { + "id": "components-form-selection-controls--all", + "exportName": "All", + "name": "Selection Controls", + "storyTags": [ + "play-fn" + ], + "effectiveTags": [ + "play-fn" + ], + "category": "PRIMARY", + "decision": "KEEP", + "candidate": null, + "reason": "Representative story or distinct reference page." + } + ] + }, { "file": "src/components/separator.stories.tsx", "title": "Components/Data & Display/Separator", @@ -3114,15 +3164,17 @@ "exportName": "All", "name": "Switch", "storyTags": [ + "verification", "play-fn" ], "effectiveTags": [ + "verification", "play-fn" ], - "category": "PRIMARY", - "decision": "KEEP", + "category": "VERIFICATION", + "decision": "HIDE", "candidate": null, - "reason": "Representative story or distinct reference page." + "reason": "Keep direct URL and tag-filter access; excluded from default sidebar." }, { "id": "components-form-switch--off", @@ -3736,12 +3788,16 @@ "id": "components-form-toggle--all", "exportName": "All", "name": "Toggle", - "storyTags": [], - "effectiveTags": [], - "category": "PRIMARY", - "decision": "KEEP", + "storyTags": [ + "verification" + ], + "effectiveTags": [ + "verification" + ], + "category": "VERIFICATION", + "decision": "HIDE", "candidate": null, - "reason": "Representative story or distinct reference page." + "reason": "Keep direct URL and tag-filter access; excluded from default sidebar." }, { "id": "components-form-toggle--states", @@ -5244,7 +5300,7 @@ { "id": "sw-footer--full-footer", "exportName": "FullFooter", - "name": "SW 하단 명령어바", + "name": "SW 명령어바", "storyTags": [], "effectiveTags": [], "category": "PRIMARY", diff --git a/WORKING_CONTEXT.md b/WORKING_CONTEXT.md index cc5ebe3..b27c0e6 100644 --- a/WORKING_CONTEXT.md +++ b/WORKING_CONTEXT.md @@ -1,5 +1,279 @@ # Working Context +## 2026-11-13 — 접기·펼치기 대표 Story 정렬 (미커밋) + +- Variants → States → Collapsible · States → Content · Usage 순서로 배치했다. 공통 StateBoard의 24px 열 간격과 라벨 스타일을 재사용하고 대표 페이지의 섹션 간격만 24px로 맞췄다. 긴 예제 2개와 Button trigger는 마지막 섹션에 배치했다. 공개 컴포넌트와 별도 검증 Story는 그대로다. +- Chrome 6027에서 1920/1440/1024/760/390px 기준 3/3/2/1/1열, PC 열 시작선 일치, 긴 문단·영문 문자열 전체 표시 및 가로 스크롤 없음 확인. 클릭·키보드·Disabled·ARIA 연결, Story ID 10개, `tsc -b`와 관련 테스트 통과. + +## 2026-11-13 — Combobox Usage 그룹 제목 제거 (미커밋) + +- 대표 Story의 `Long option`·`Select / Combobox` 제목과 래퍼를 제거했다. 개별 라벨 5개, 기존 그리드·들여쓰기·Story ID를 유지했다. Chrome 6027에서 1440px 입력 3개 상단 일치, 1024/760/390px 기존 2/1/1열 및 가로 스크롤 없음 확인. Select 선택과 비활성 옵션, Combobox 검색·선택·포커스 복귀, `tsc -b`, 컴포넌트 테스트 9건 통과. + +## 2026-11-13 — 날짜 선택: PC 열 정렬 및 반응형 정리 (미커밋) + +- DatePicker 대표 Story 전용 4열 기준으로 Sizes·States·Usage와 Calendar의 시작선을 맞췄다. 섹션 간 24px, 열 간 16px. Usage 제목은 중복 라벨을 대신하며 입력창과 팝업의 접근성 이름으로 연결했다. +- Calendar의 Variants/States 섹션과 기존 Story ID는 유지했다. Two months는 아래 왼쪽에 배치하며, 넓은 화면에서는 기존 544px 너비, 좁은 화면에서는 기존 월 줄바꿈을 유지한다. +- Chrome 6027: 1920/1440에서 4열 기준선 일치, 1024에서 입력·달력 첫 행 2열, 760에서 입력 2열·달력 1열, 390에서 모두 1열. 전체 폭에서 가로 스크롤 없음. 1920×1080에서는 공통 열을 적용한 최종 콘텐츠 높이가 1134px로, Two months 하단까지 약 30px 스크롤 필요. 날짜 선택·비활성 조건·포커스 복귀·Story ID 확인, 타입 검사와 관련 테스트 통과. + +## 2026-11-13 — 조직도 › 개요 → `가족사 현황`: 명칭 통일·로고 카드 테두리 제거 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- 메뉴: `org-chart-p1.stories.tsx` title `조직도/가족사 현황`, Story name `가족사 현황`, `.storybook/preview.tsx` storySort 항목도 같은 이름(위치 첫 번째 유지). Story ID `orgchart--logo-personnel-summary`(`__id`)·export명 불변이라 기존 verify 스크립트·링크 영향 없음. +- 텍스트(`org-chart-page-previews.tsx`): 페이지 제목 `가족사 현황`, 섹션 `가족사 로고`·`가족사별 인원 현황`. `조직·직위별 인원 현황` 유지. 패키지 내부 문구(`logo-legend.ts`의 h2는 이 페이지에서 제거됨, `affiliation-bar` aria-label, 직원 상세 Dialog `관계사` 항목)는 공용 Core라 미변경. +- 테두리: `core-component-preview.tsx`의 `hideLogoHeading` 분기(사용처는 이 페이지 하나)에서 로고 `li`에 inline `border: 0`. padding 16px·로고 96×96·grid gap 16px 유지로 간격만으로 구분. 패키지 CSS·배경·그림자 미변경. +- 검증(6024 iframe, 1400·1024·760·390): 화면 텍스트 `관계사` 0, 로고 7개(순서 동일) border 0, 로고·회사명 중앙 정렬 오차 0, 열 6/5/3/1, 가로 Overflow 0. `tsc -b` 통과. 스크린샷은 pane 렌더 시간 초과로 미첨부. `.storybook/qa` 인벤토리(title·name) 재생성은 미실행. + +## 2026-11-13 — 노선도 › 시설물 › 연계 시설물: 카드 배치 재구성 (POSITION | ACTUAL, 2열 카드) (미커밋) + +- 범위: `src/center/center-stories.css`만(선택자 `.sb-center-linked-page` 범위). 패키지 Demo DOM·공개 API·SVG·Props·색상·다른 페이지·Story ID·메뉴 불변. 아래 "상단 안내·중복 명칭 제거" 기록의 숨김(안내·READY·카드 첫 이름)은 유지. +- 측정(17개 카드): POSITION 최대 240px(지방도 터널 분할), ACTUAL 최대 252px(교차로 계열) → 카드 내부 2열 240 + 24 + 252 = 516px, 카드 2열 516 × 2 + 24 = 1056px. +- 구조: 목록 `.linked-facility`를 container `sb-linked-list`로. 카드 그리드 기본 1열, ≥ 1056px 2열(gap 24, 홀수 마지막 카드 왼쪽). 카드 = 제목(14/20/700) + 패키지 `__variant` 2개를 열로: ≥ 516px POSITION | ACTUAL 좌우(subgrid로 라벨·기본·분할 행 정렬, 열 간 24), 미만 세로(블록 사이 24). 열 라벨 `POSITION` / `ACTUAL` 11px(사용자 A안 선택), 라벨 → 기본 8px, 기본 → 분할 16px(같은 행의 실사화 기본이 2px 더 높아 위치도 쪽 측정 18px). 카드 프레임 없음. +- 삭제: 기존 base `sb-linked-card` 좁은 카드 라벨 쌓기 규칙과 64px 라벨 열 규칙(연계 시설물 전용, 새 구조로 대체). +- 검증(6024 HMR, iframe): 1400(목록 1309) 카드 2열·내부 2열, 1024(933)·760(669) 카드 1열·내부 2열, 390(331) 카드 1열·내부 세로. 그룹 5개(5·3·3·3·3)·카드 17·figure 68·깨짐 0, 제목 중복 0, 안내·READY 미표시, 등급 제목 16px x=46(390: 28) → 카드 12px, 섹션·카드 간격 24, 행 정렬 불일치 0, figure 너비 불변(239·202·252·218), 로컬 스크롤 0, 문서 가로 Overflow 0. `tsc -b` 통과, `storybook-inventory.mjs` 1/1. + +## 2026-11-13 — 노선도 › 시설물 › 연계 시설물: 상단 안내·중복 명칭 제거, 시작 위치·라벨 통일 (미커밋) + +- 범위: `center-facility.stories.tsx`(Linked Story만 `sb-page-indent` 래퍼 추가), `center-stories.css`(선택자 `.sb-center-linked-page` 범위). 패키지 Demo·공개 API·SVG·Props·색상·토큰·다른 페이지·Story ID·메뉴 불변. +- CSS 숨김(실제 반복 표시만): 상단 안내 `.linked-facility__context`(문구 2개 + 회색 막대), 등급 제목 옆 `N READY 시설물 집합`(카드 개수 반복), 카드 첫 이름 `strong`(터널·교량·교차로는 한글 이름 반복, JC·IC는 그래픽에 표기). 카드 제목은 한글 이름 1회(14/20/700 primary). +- 정렬: 등급 제목·카드 시작 x = 터널 첫 제목(1400·1024·760: 46, 390: 28), 첫 제목 top도 터널과 동일. 카드 그리드의 자체 들여쓰기 제거. +- POSITION / ACTUAL: 다른 시설물 행 라벨과 동일(11px/700/.08em, 64px 열, 간격 16px). 좁은 카드 라벨 위로 쌓기 기준 365 → 331px(64 + 16 + 최대 252px 그래픽), 이 페이지 64px 규칙이 쌓기 규칙보다 우선하므로 scoped container 재정의 추가. +- 검증(6024 HMR, iframe 1400·1024·760·390): 안내·READY 미표시, 카드 제목 터널/교량/교차로/분기점/나들목 1회, 그룹 5개·카드 17개(5+3+3+3+3)·figure 68, 등급 제목 16/24/700 → 카드 12px, 섹션 24px, 카드 제목 → 그래픽 12px(760·390은 라벨이 위로 쌓여 41px, 기존 동작), 그리드 3/2/2/1열, figure 너비 불변, 이미지 깨짐 0, 로컬 스크롤 0, 문서 가로 Overflow 0. 1400 페이지 높이 3082 → 2838. `tsc -b` 통과, `storybook-inventory.mjs` 1/1. + +## 2026-11-13 — 노선도 › 시설물: 교량·교차로·분기점·나들목·연계 노선·연계 시설물을 터널 레이아웃으로 통일 (미커밋) + +- 범위: `src/center/center-facility.stories.tsx`, `src/center/center-stories.css`만. 패키지·dist·SVG·토큰·Story ID·메뉴명·순서·터널 화면 불변. +- 교량·교차로·분기점·나들목: 패키지 `FacilityReferenceDemo` 대신 공용 Story 컴포넌트 `FacilitySpecimens`(공개 API `LocalAssetFacility`·`FACILITY_CONFIGS`·`CompactFacilityConnection`, Demo와 같은 Props). Position Map | Actual / Detail 2열, 각 열 SEPARATED → SHORT/MEDIUM/LONG(ACTUAL SHORT/REF/LONG), 간략 적용 예제 하단 전체 너비(SOURCE 문구·bridge 구간 모양 유지). 상단 단독 위치도·실사 예제 = MEDIUM / ACTUAL REF 행(기본 이름·보조 문구 동일)이라 제거, Separated 예제는 각 열 첫 행으로 이동, `너비 동작` 제목 제거. +- 연계 노선: `RoadsideSpecimens`(공개 API `RoadsideFacilityTag`). 행 라벨을 다른 시설물과 같은 1줄 64px 라벨(SHORT/MEDIUM/LONG, ACTUAL …)로 공통화(사용자 요청), 열 보조 설명은 `위치도`/`실사화`만(사용자 요청으로 `콘텐츠 기반 너비`·`고정 양 끝 + 가변 중앙` 삭제). 분할은 실사화 첫 행, 2열에서 위치도 첫 행은 빈 행 `.sb-center-roadside__blank`, 1열에서는 숨기고 그룹 span 4. 위치도 태그는 `div`로 감쌌다 — 태그가 `span`이라 패키지 행 라벨 규칙(`.facility-reference__width-tests > div > span`, 11px)이 태그 글자에 적용되던 문제 방지. +- 연계 시설물: 패키지 Demo·카드 그대로. CSS만 — 등급 그룹 사이 28 → 24px, 그룹 제목 옆 설명 12/18 회색·간격 8px·왼쪽 정렬(기존 space-between 14px). +- CSS 전환 기준: 터널 1180px 블록을 `.sb-center-tunnel:not([data-sb-compare-at])`로 한정, 교량만 `data-sb-compare-at="1210"`(LONG 511px: 64+16+511 ×2 + 24 = 1206) 별도 블록. 연계 노선은 라벨 공통화로 64+16+450 → 1180px 사용(승인 시 1230px 안은 라벨 변경으로 불필요해져 제외). 빈 행 `display:none`은 패키지 `> div { display:grid }`보다 우선하도록 `.sb-center-roadside__position > ` 선택자. +- 검증(6024 HMR, 서버 재시작 없음, iframe 1400·1024·760·390): 7개 페이지 1400 2열·행 top 일치, 이하 1열(그룹 사이 24px, 겹침 없음), 제목 16/24/700 + 설명 12px 회색·간격 8px, 제목 → 첫 행 12px, 섹션 24px, 이미지 깨짐 0, figure 너비 변경 전과 동일, 390에서 stage 2개 로컬 스크롤(8px 규칙 유지), 문서 가로 Overflow 0. 1400 페이지 높이 교량 1299 → 900, 교차로·분기점·나들목 1046 → 900(뷰포트). `tsc -b` 통과, `packages/sw/tests/storybook-inventory.mjs` 1/1 통과. Storybook 전체 창(LNB 180px) 1400에서 교량은 콘텐츠 1210px 미만이면 1열. + +## 2026-11-13 — 노선도 › 터널: `너비 동작` 제목 제거, 열 제목을 공통 섹션 타이틀로 (미커밋) + +- `center-facility.stories.tsx`: `너비 동작 · CENTER만 수평 확장` 제목 행 삭제(stage는 `tunnel-reference__sample` 안에 유지). 열 제목을 `header > strong + span`으로 — `Position Map` + `위치도`, `Actual / Detail` + `실사화`. +- `center-stories.css`: 공통 예제 제목 행 규칙 3개의 `:is()` 목록에 `.sb-center-tunnel__width-group` 추가(16/24/700 + 12px 회색, 같은 줄, 간격 8px). 예전 열 라벨 규칙 삭제. 제목이 없어진 stage는 상단 padding 0, 열 제목 → 첫 행은 subgrid 16px − 4px = 12px. +- 검증(6024): 1400 두 제목 top·높이(24px) 동일, 4단계 행 정렬 유지. 1024·760·390 1열(그룹 사이 24px). 안내 문구 → 첫 제목 16px, 섹션 간 24px, 행 라벨 8개·figure 너비 불변, 390 stage 8px 스크롤바·끝까지 스크롤, 문서 가로 Overflow 0. `tsc -b` 통과. + +## 2026-11-13 — 노선도 › 터널: 상단 영역 제거, 너비 동작 한 섹션으로 통합 (아래 "상단 3열" 기록을 대체, 미커밋) + +- `center-facility.stories.tsx` `TunnelSpecimens`: 상단 Position Map / Actual / Separated 영역 삭제. `너비 동작` 각 열에 SEPARATED 행 추가 — 왼쪽 Position Map: SEPARATED → SHORT → MEDIUM → LONG, 오른쪽 Actual / Detail: SEPARATED → ACTUAL SHORT → ACTUAL REF → ACTUAL LONG. 삭제한 단독 위치도·실사 예제는 MEDIUM / ACTUAL REF와 같은 그래픽(대산2터널 L=750m). 간략 적용 예제는 하단 유지. +- `center-stories.css`: `.sb-center-tunnel__top`·`__stack`(3열 상단) 규칙 삭제, 그룹 subgrid 행 4 → 5. 1180px 2열/1열 전환·8px 스크롤바 규칙 유지. +- 검증(6024): 1400(콘텐츠 1324) 2열·4단계 행 위치 일치, 1024·760·390 1열(그룹 사이 24px). 섹션 2개(너비 동작·간략 적용 예제), 행 라벨 8개, 제목 → 그래픽 12px, 섹션 간 24px, figure 크기 불변(분할 197×93·199×95 등), 390에서 stage 8px 스크롤바·끝까지 스크롤, 문서 가로 Overflow 0. `tsc -b` 통과. + +## 2026-11-13 — 노선도 › 터널: 상단 3열·너비 동작 2열 그룹 배치 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- `src/center/center-facility.stories.tsx`: `Tunnel`만 패키지 `TunnelReferenceDemo` 대신 Story의 `TunnelSpecimens`(공개 API `TunnelFacility`·`CompactFacilityConnection`, 데모와 같은 Props·문구)로 구성. 패키지 클래스(`tunnel-reference__sample`/`__stage`/`__width-tests`)를 재사용해 공통 제목 행·12px·8px 스크롤바가 그대로 적용. Story ID·이름·순서·`TunnelPlayground`·다른 시설물 페이지 불변. +- `src/center/center-stories.css` 끝에 `.sb-center-tunnel*` 규칙만 추가(container query, 기존 8px 스크롤바 규칙 보존): 상단 Position Map | Actual / Detail | Separated(분할 2개 세로 16px) 3열 ≥ 720px, 미만 1열. 너비 동작은 열 제목 `Position Map`/`Actual / Detail`(14/20/500 회색) + SHORT·MEDIUM·LONG | ACTUAL SHORT·REF·LONG, subgrid로 같은 길이 행 정렬, 2열 ≥ 1180px(행 최대 577px × 2 + 24), 미만 1열(그룹 사이 24px). 간략 적용 예제는 아래 전체 너비. +- 검증(6024): 1400(콘텐츠 1309) 3열·2열·행 정렬 일치, 1024(933) 3열·1열, 760·390·320 1열·1열. 문구·라벨 누락 0, 제목 → 그래픽 12px, 예제 간 24px, figure 크기 모든 폭 동일(210×93 … 497×95), 390·320에서 너비 동작·간략 예제 stage 8px 스크롤바 + 끝까지 스크롤, 문서 가로 Overflow 0. `tsc -b` 통과. +- 테스트: `verify-p9-4.mjs`는 실행하면 untracked 기준 파일 `test-results/org-chart-p9-4/browser.json`을 덮어쓰므로 실행하지 않고, 터널 비교 항목만 브라우저에서 재현. 터널 첫 예제 프레임·stage 스타일 ≠ OrgChart Department(투명·padding 12px 0 0 vs 카드형) — 2026-11-12 노선도 카드 프레임 제거(`--plain`) 이후의 기존 차이이며 이번 변경과 무관(같은 클래스·같은 규칙). + +## 2026-11-13 — SW Viewer 로고·문서 메뉴 테스트 잔여 7건: 승인 규격 기대값 반영 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- `packages/sw/tests/viewer-logo-document-menu.browser.mjs`만 수정(SW 컴포넌트·Story·CSS·Props 불변). 7건 모두 2026-10-21~23 승인 변경 이전의 기대값이었다: 파일 열기 아이콘 = 실제 아이콘 1 + 외곽선 1 SVG(caf4c32), 아이콘 영역 16 × 16·클릭 영역(버튼) ≥ 34px(0840d76, 실측 104 × 50 / 아이콘만 37 × 50), 모든 sweep 폭에서 같은 규격; 좁은 헤더 hover = 면 없음(투명)·글자 #FFC600(caf4c32); sweep은 "More 전 아이콘 + 패널 토글이 전체 보기보다 먼저(2026-10-23) + 아이콘이 된 버튼은 좁아질수록 라벨 복귀 없음(More 이동 허용)", 항목 수 단조 감소 대신 More가 보이는 21개 폭(650~400)마다 More를 실제로 열어 8개 명령이 헤더·More 중 정확히 한 곳(패널 토글은 More 금지, 메뉴는 화면 안, Escape 닫힘, 페이지 넘침 없음), 파일명 길이와 무관하게 같은 전환 폭(아이콘 1040 / More 650, caf4c32의 Header 폭 기준). +- 결과: viewer-logo-document-menu 49/49(검사 1개 추가: ⑤를 순서·라벨 복귀 금지 두 검사로 분리), viewer 103/103, property-panel-comparison 59/59. 작업 중 새로 넣은 검사의 판정 오류 1건(More로 이동한 버튼을 라벨 복귀로 셈)은 라벨이 다시 보이는지로 정정. + +## 2026-11-13 — 노선도 그래픽 내부 가로 스크롤 표시 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- 점검: 10개 페이지 × 1400·1024·760·390. 레이아웃 컨테이너 잘림 0(시설물 탐지는 `visibility:hidden; position:fixed` 측정용 span), 인라인 SVG viewBox 초과 0, 깨진 이미지 0. "잘려 보임"의 원인은 실제 크기 그래픽이 `overflow-x: auto` stage 안에서 넘치는데 스크롤바가 overlay(높이 0)라 스크롤 표시가 없던 것. +- 수정: `src/center/center-stories.css` 끝에만 규칙 추가. `.sb-center--plain` 안의 `.tunnel-reference__stage`·`.facility-reference__stage`·`.center-title-reference__stage`·`.cid-demo__stage`·`.linked-facility__variant`·`.csi-panel > div`에 `::-webkit-scrollbar` 높이 8px, Thumb는 HM Scroll Area 토큰(`--hm-scroll-area-thumb-background`, hover `…-hover-background`), 트랙 투명. 비 WebKit은 `@supports not selector(::-webkit-scrollbar)`로 `scrollbar-width: thin` + 같은 색. overflow 동작은 그대로라 넘칠 때만 표시. 패키지·dist·다른 메뉴 불변. +- 검증(6024, Chromium 기반 앱 브라우저): 넘치는 stage 44개 전부 스크롤바 8px, 넘치지 않는 stage 스크롤바 0, 프로그램 스크롤로 끝까지 이동 44/44, 사업명 760에서 실제 마우스 가로 스크롤로 끝(205/205px)까지 이동·그래픽 오른쪽 끝 표시 확인. 제목 → 그래픽 12px·예제 간 24px 유지, figure 크기 모든 폭에서 동일, 문서 가로 Overflow 0. 미검증: Firefox·Safari(표준 thin 스크롤바 경로)와 OS overlay 설정이 다른 실제 사용자 브라우저. + +## 2026-11-13 — SW 테스트 잔여 2건: SW 범위 Story 집합 검사, 로고 메뉴 More 정책 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- `storybook-inventory.mjs`: 승인 Step Header Story 10개(`stepHeaderStoryIds`, adbb034)와 `swScopeIssues` 추가 — 제목이 `SW/`인 Story ID 집합이 "c8e5fbe 이후 유지된 SW/Header(승인 변경 반영) + 승인 추가 목록"과 정확히 같은지 검사(unexpected = 무단 추가·ID 변경, missing = 삭제·ID 변경). `property-panel-comparison.browser.mjs`: 전체 Story 수 공식(338 − 9 + 30)을 `swScope.ok`로 교체(그 1줄과 import만; 공식이 이 파일에 있어 함께 수정). SW 밖 Story 추가·삭제는 영향 없음, 태그(internal·verification) 검사는 기존 header 검사가 유지 — 메모리 시뮬레이션으로 SW 무단 추가·삭제·ID 변경·태그 제거 감지 확인. +- `viewer-logo-document-menu.browser.mjs`: 2026-10-26 More 정책(약 520px 미만에서만 More)에 맞춰 More를 여는 세 곳을 400px로(520 행을 제자리에서 400px로 줄임 / 헤더 400px, viewer.browser.mjs와 같은 방식), 860·760·520 기대를 "아이콘만, More 없음"으로, 항목 1회 보유 검사는 400px 축소 상태에서 수행 후 복원. +- 결과: property-panel-comparison 59/59(swScope 50 = 50), viewer 103/103, viewer-logo-document-menu 41/48(이전엔 1단계에서 중단). 남은 7건은 이번에 처음 실행된 검사의 옛 기대값: 파일 열기 아이콘 34px 기대 ↔ 현재 16px(3건), 좁은 헤더 hover 색(1건) — 2026-10-21 이후 Viewer Header 수정; sweep 3건 — 1050~1310px에서 패널 토글만 아이콘(전체 보기 라벨 유지, 2026-10-23 승인), 560px 미만 파일명 56px로 항목이 More에서 돌아옴(비단조), 파일명 폭 고정으로 길이와 무관(2026-10-26). 제품 코드 미변경, 판정 재정의는 별도 승인 필요. + +## 2026-11-13 — SW 테스트 기존 실패 정리: 인벤토리 internal 태그·Dropdown 스크롤·로고 메뉴 뷰포트 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- 테스트 파일 3개만 수정(SW 컴포넌트·Story·CSS·Story ID 불변). `packages/sw/tests/storybook-inventory.mjs`: 승인 commit 2378663(2026-11-09)이 `sw-header--logo-slot`에 붙인 `internal` 태그를 `internalTaggedIds`로 기대값에 반영(`withApprovedEntryChanges`). `property-panel-comparison.browser.mjs`: "Dropdown follows the page scroll" 검사를 반복문 밖 별도 단계로 옮겨 1440 × 600(페이지가 스크롤되는 높이)에서 `behavior: "instant"`로 실행 후 크기·스크롤 복원, 판정 조건(이동 > 0, 필드 부착 1.5px 이내) 그대로. 원인: 3열 배치 이후 1440 × 900에서 페이지가 화면보다 짧고, `src/hm-theme.css`의 `html { scroll-behavior: smooth }`로 직후 scrollY가 0. `viewer-logo-document-menu.browser.mjs`: 화면 너비 페이지를 여는 두 곳을 1920 → 2500px(1920 행은 1980px 이상 페이지에서만 표시, 정책 유지). +- 결과: property-panel-comparison 58/59(이전 56/59, Dropdown 스크롤·인벤토리 태그 해결), viewer 103/103(회귀 없음). 남은 실패: ① 인벤토리 첫 검사의 전체 Story 수 조건(388 ≠ 338 − 9 + 30 = 359) — SW 변경만 반영하는 공식이라 2026-10-20 이후 추가된 비 SW Story(Chart·Selection Controls 등)를 세지 못함, 기존 문제·승인 범위 밖. ② viewer-logo-document-menu: 시간 초과는 해결됐지만 다음 단계가 520 행의 More(추가 도구)를 누르는데 2026-10-26 규칙(More는 약 520px 미만에서만, viewer.browser.mjs는 520 행을 400px로 줄여 검사)으로 520 행에 More가 없어 중단. 테스트 기대가 2026-10-21 규칙(860·760·520에서 More) 기준이라 판정 조건 갱신이 필요하며 이번에는 수정하지 않음. 이후 검사는 실행되지 못함. 정적 빌드가 필요한 테스트(dropdown-consolidation·overview·storybook-unused-removal)는 실행하지 않음. + +## 2026-11-13 — 노선도 데모 설명 정리: 기술·제작 설명 제거, 규격은 문서 보존 (브랜치 `style/storybook-ui-refinements`, 미커밋, 패키지 수정 승인됨) + +- 패키지 `packages/center-graphic/src/components`의 `*ReferenceDemo` 문자열만 수정(컴포넌트 API·Props·CSS·그래픽·`index.ts` 불변, CRLF 유지): `TunnelFacility.tsx`·`FacilityBatch.tsx`(로컬 SVG → 위치도/실사화, Separated 설명 제거, `Source 정의` 제거, 연계 노선 `재구성 Primitive`·`Source 작성 …`·`13 / 90 / 13` 제거, `태그 계약` → `태그`), `CenterInformationDisplay.tsx`(정의 목록 구조 설명·테이블 행 높이·강조형 1 겹침 설명 제거, `Source 팔레트` → `팔레트`, 동적 콘텐츠 → `한 줄 항목은 말줄임, 명시적 줄바꿈 유지`), `CenterSpatialInformation.tsx`(1·1b legend 제거, 2·3 legend는 사용 기준 문장만). 유지: `SOURCE_RECEIVED`/`SOURCE_PENDING` 설명, `KNOWN_DEVIATION`, `READY`, 너비 테스트 라벨, 4·5·6·7 사용 기준·미확정 표기, 노선·공간 상태·입력. `npm run build:center-graphic` 1회(dist는 Git 미추적). +- 문서: `docs/center-graphic/README.md` §4에 화면에서 뺀 수치를 코드와 대조해 추가(윤곽 3.6u, 그림자 Blur 6u 70%, Dark/White Outline 분류, 공사중 32u/32u·계획 45° 빗금, 사업 노선 #D21203·150%, 노선명 54u/9u, 번호 64u, 방면 50u·80% 2u·7점, 정보 테이블 26.116/34.364u(=26.116 + 8.248), 강조형 1 4u 겹침, 연계 노선 간격 19/14). `13 / 90 / 13`은 코드 미확인 Source 표기로 비확정 명시. §7 공통 레이아웃 문단 갱신. +- 검증(6024, 10개 페이지 × 1400·1024·760·390): 제거 대상 문구 23종 잔존 0, 필수 상태·비교 문구 누락 0, 제목 → 그래픽 12px, 노선·공간 h4 → 캔버스 12px(설명 있으면 4px), 안내 오른쪽 끝, 문서 가로 Overflow 0, 깨진 이미지 0. `tsc -b`·center-graphic `typecheck` 통과. + +## 2026-11-13 — Storybook 미해결 사항: SW 화면 너비·390px 넘침, Input States, Selection Focus, 문서·주석 정합성 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- SW 화면 너비 4개 페이지(SW 헤더·뷰어·단계별 헤더·명령어바 › 화면 너비): 행 제목의 폭과 실제 폭이 같도록, Story 열보다 넓은 행은 숨기고(`data-sw-width-row`) 페이지의 가장 좁은 행(760 / 520 / 1100, `data-sw-width-min`)은 항상 표시하며 열이 더 좁으면 그 폭을 유지한 채 행 안에서 스크롤한다(`sw-stories.css` 컨테이너 `sw-story-rows`, `.sw-story-fit__scroll`). 최소 폭·overflow는 열이 좁을 때만 적용되어 넓은 화면의 열린 메뉴가 잘리지 않고 행을 제자리에서 줄이는 검사(viewer More)도 그대로 동작한다. 각 페이지의 너비 세트는 유지. 명령어바 화면 너비는 다른 세 페이지처럼 `sw-story-stack`(flex gap)으로 행 간격을 두고 형제 margin 규칙 2개를 삭제했다(`sw-footer-stories.css`). +- SW 명령어바(760)·뷰어 로고(520): Story 열이 그보다 좁으면 예제가 그 폭을 유지하고 섹션 안에서 스크롤(`.sw-story-fit`), 페이지 가로 넘침 없음. SW 설정창: 액자 최소 520px(이미 있던 스크롤 영역 안)로 390px에서도 340px 옵션 팝업이 액자 안에 들어간다. SW 컴포넌트 크기·최소 폭·API는 변경 없음. +- Input States: Disabled를 Focus·Validation과 같은 Medium(34px)으로. Readonly는 컴포넌트 규칙(텍스트만, 항상 Large 수치)이라 42px 유지. +- Selection Controls Focus: `.storybook/pseudo-states.ts`에 `:focus-visible` → `data-sb-focus` 복제를 추가(부정형 `:not(:focus-visible)` 규칙은 복제하지 않음). Radio Group(Option A)·Switch Focus 칸에 링 고정, Checkbox는 기존 `play` 실제 포커스 유지. 실제 Tab 포커스 링 동작 동일. + - 후속 수정(2026-11-13): 위 복제가 `:focus-visible:active` 같은 복합 규칙을 `[data-sb-focus][data-sb-active]`로만 만들어 Slider `Focus` 검증 Story의 `Focus + Drag`(실제 포커스 + `data-sb-active`)가 실패했다. 기존 복제본(`:focus-visible` 유지, hover/active만 교체)도 함께 만들도록 보완. 검증: Slider Focus play 통과, Selection Controls·Button·Input·Checkbox에서 복제 규칙 삭제 0·추가 9개(모두 기존 방식 복제본), Selection Controls 고정 포커스 링 동일, `tsc -b`·Storybook build 통과. +- 문서: 프로젝트-지침 §6(구분선·16px 제거, 좌우 최대 30px), combobox·dialog·dropdown-menu·toggle·sonner·navigation-menu·lnb·checkbox·radio-group·switch·input, chart README 높이. 주석: foundation.css·center-stories.css(1줄)·chart-story·story-board·Story 공통 문구의 "divider", tabs 28px, carousel 중복 주석, org-chart-expanded의 적용되지 않던 `margin-top: 24px`(계산값 0px) 삭제. +- SW 테스트: 인벤토리 기대 이름을 현재 Story 이름(속성창·화면 너비·설정창·로고·SW 명령어바)으로 갱신. property-panel-comparison 56/59(남은 실패: 인벤토리 2건 = `sw-header--logo-slot`의 `internal` 태그(258e0a4)가 승인 태그 목록에 없음, Dropdown 페이지 스크롤 1건 — 모두 기존), viewer 103/103. viewer-logo-document-menu는 1920px 화면에서 숨겨진 1920 행의 헤더를 기다리다 시간 초과(2026-10-23 이후 기존 문제). + +## 2026-11-13 — 노선도 메뉴 전체에 사업명 페이지 레이아웃 공통 적용 (A안, 브랜치 `style/storybook-ui-refinements`, 미커밋) + +- Story: `center-facility.stories.tsx`(`page()` — 터널·교량·교차로·분기점·나들목·연계 노선), `center-information.stories.tsx`, `center-spatial.stories.tsx`에서 `StorySection title="개요"` 제거 → 공통 `sb-page-indent`로 기존 예제 들여쓰기 유지. 연계 시설물(원래 개요 없음, 들여쓰기 없음)·사업명은 불변. Story ID·이름·순서·Playground 불변. +- CSS(`center-stories.css`, `.sb-center--plain` 범위): 사업명 전용 제목 행 규칙을 공통 규칙 하나로 통합 — 터널·시설물·사업명·정보 데모 헤더(`header > strong|h3 + span`)를 한 행(flex-wrap, baseline, 간격 8px)으로, 제목 16/24/700 primary, 설명·사이즈 12/18 secondary. 노선·공간 `h4`도 16/24/700(긴 설명 legend/note는 제목 아래 14px 그대로). 연계 시설물 카드 라벨은 기존 14/500 유지. 배율 안내 `.center-representation__note`는 독립 행 오른쪽 끝(flex-end, 줄바꿈), 사업명만 숨기고 첫 제목 행에 표시(숨김 선택자 특이도 상향). 섹션 제거로 사용처가 없어진 `.sb-page-section-body > .sb-center--plain` 들여쓰기와 사업명 헤더 `> div` gap 규칙 삭제. 패키지 문구(Source·SOURCE_RECEIVED/PENDING·번호)는 그대로. +- 문서: `docs/center-graphic/README.md` §7에 공통 레이아웃 기준 한 단락. +- 검증(6024, 페이지 10개 × 1400·1024·760·390·320): `개요` 0, 안내 오른쪽 끝 = Inner 오른쪽 끝(사업명은 제목 행), 모든 예제 헤더 16/24/700 + 12px·8px, 제목 → 그래픽 12px, 노선·공간 h4 16px/700, 문서 가로 Overflow 0. 1400·1024는 전 헤더 한 줄, 760 정보 1개, 390·320은 긴 설명(정보·연계 노선 등)이 다음 줄로 줄바꿈. 교량 1024 예제 간 24px·열 간 24px. `tsc -b` 통과. 그래픽·stage 규칙은 미변경. +- 미해결: 안내 문구의 첫 제목 행 배치와 Source 문구 정리는 패키지 데모 공통화 작업에서 별도 검토. + +## 2026-11-13 — 노선도 › 사업명: 배율 안내를 첫 예제 제목 행 오른쪽으로 이동 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- 아래 "`개요` 제거·우측 정렬" 기록의 후속. 패키지 `CenterRepresentation`의 독립 안내(`.center-representation__note`)는 이 페이지에서만 `display: none`(`.sb-center-title-page`). 같은 문구를 `center-title.stories.tsx`의 첫 예제 제목 행에 `.sb-center-title-note`로 렌더(문구는 `centerTargetResolution()` 기준 100% · 7680 / 75% · 3840, 패키지와 같은 규칙). +- `center-stories.css`: 이전 우측 정렬 규칙을 위 숨김 + `.sb-center-title-note { margin-left: auto; text-align: right }`로 교체. 줄바꿈되는 flex 행에서 "구성명 + 사이즈 | 안내" 양끝 정렬, 공간 부족 시 안내만 다음 줄 오른쪽으로 이동. 스타일은 사이즈와 같은 12px 회색. +- 검증(6024): 1400·1024·760·390 모두 안내가 첫 제목 행과 같은 줄, 오른쪽 끝 = Inner 오른쪽 끝(1355 / 979 / 730 / 374). 별도 안내 영역 없음(첫 제목 행이 페이지 상단 gutter에서 시작). 예제 5개 제목→그래픽 12px·간격 8px·예제 간 24px·그래픽 크기 불변. 320px 추가 확인: 제목 + 사이즈는 한 줄, 안내만 다음 줄 오른쪽. 문서 가로 Overflow 없음, `tsc -b` 통과. 패키지·dist 미수정. + +## 2026-11-13 — 노선도 › 사업명: `개요` 타이틀 제거·배율 안내 우측 정렬 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- `src/center/center-title.stories.tsx`의 `Compositions`: `StorySection title="개요"` 제거. 예제는 공통 `sb-page-indent`(16px, ≤640px 12px) 안에 둬 기존 x 위치를 유지한다. `StoryPage className="sb-center-title-page"` 추가. Story ID·`Playground`·예제 5개 구성은 불변. +- `src/center/center-stories.css` 끝에 `.sb-center-title-page .center-representation__note` 규칙만 추가(`display:flex; justify-content:flex-end; text-align:right; overflow-wrap:anywhere`). 패키지 `CenterRepresentation`·dist·다른 노선도 페이지는 불변(클래스가 이 Story에만 있음). +- 검증(6024): 1400·1024·760·390 모두 H2 0개·`개요` 텍스트 없음. 안내 문구 오른쪽 끝 = Inner 오른쪽 끝(1355 / 979 / 730 / 374), 한 줄. 첫 예제 제목은 안내 문구 16px 아래(패키지 note margin), x = Inner + 16(390은 +12)로 이전과 같다. 예제 5개 한 줄·간격 8px·제목→그래픽 12px·예제 간 24px·그래픽 크기 불변(390의 긴 콘텐츠 설명 줄바꿈은 이전과 같음). 문서 가로 Overflow 없음. `tsc -b` 통과. + +## 2026-11-13 — 노선도 › 사업명: Type·Source 문구 제거, 구성명 + 사이즈 한 줄 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- `src/center/center-title.stories.tsx`의 `Compositions`(Story ID `center-graphic-title--compositions` 유지)만 변경. 패키지 데모 `CenterTitleReferenceDemo` 대신 공개 API(`CenterTitle`, `CENTER_TITLE_COMPOSITIONS`)로 예제 5개를 Story에서 직접 구성한다. 제목 = 구성명(H3), 오른쪽 사이즈(`620 × 100` 등). 긴 콘텐츠는 제목 `노선구분 + 사업명 (긴 콘텐츠)` + 기존 설명 "같은 연결과 고정 외곽 지오메트리; 콘텐츠 기반 너비". `Type 1~4`·`Source 근거 뷰포트` 표시 제거. 패키지(`packages/center-graphic`)·dist·`Playground`·상단 `표시 배율 · 제작 대상` 안내는 불변. +- `src/center/center-stories.css` 끝에 `.sb-center--plain … header.sb-center-title-row` 규칙만 추가: 제목은 공통 섹션 제목(16/24/700 primary), 사이즈는 12/18 secondary, 간격 8px, 모든 폭에서 한 줄(공간 부족 시에만 줄바꿈 — 패키지의 ≤720px 세로 배치를 이 페이지에서만 덮음). 제목 → 그래픽 12px(기존 stage 상단 padding), 예제 간 24px(기존) 유지. 그래픽 영역 클래스 `.center-title-reference__stage` 재사용으로 배경·크기 불변. +- 검증(6024): 1400·1024·760에서 5개 모두 한 줄·간격 8px·제목→그래픽 12px·예제 간 24px, 그래픽 620×100 / 610×167 / 890×100 / 782×167 / 긴 콘텐츠 1695×100(기존처럼 stage 로컬 스크롤). 390에서 고정 사이즈 4개는 한 줄, 긴 콘텐츠 설명만 다음 줄로 넘어감(의도된 줄바꿈). 모든 폭에서 문서 가로 Overflow 없음, Type·Source 문구 0. `tsc -b` 통과. + +## 2026-11-13 — Storybook 전체 점검 후속: P1 4건·중복 UI 요소 제거 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- 원칙(사용자 정정): 중복 정리는 문구 번역·재작성이 아니라 같은 정보를 반복하는 UI 요소 자체를 제거하는 것. 버튼·폼 Label·aria-label·필요한 H2/H3는 유지하고, 차트처럼 한 행에 놓인 예제는 정렬을 유지한다. 번역·`열기` 일괄 치환·Title Case·Invalid→Error·Usage/Default 명칭 변경은 원복했다. +- P1: Toggle Button Sizes 라벨 Default→Medium·글자 토글의 prop 값(`sm/default/lg`)→예시 문구 "알림"(`toggle-examples.tsx`), Collapsible 대표 화면 디버그 출력 제거(`collapsible-examples.tsx`), Combobox·Date Picker Sizes `Default`→`Large`(기본 크기가 실제 large), SW 설정창 패널 배치를 Story 열 폭에 따라 3/2/1열로(1076px 이상 기존 24/368/712, 700px 이상 2열, 미만 1열; `sw-viewer.stories.tsx`, 1024px 겹침 해소, 390px에서 340px 옵션 팝업은 프레임보다 7px 넓음). +- 중복 제거: 비활성 항목의 "(사용 불가)"(메뉴바·팝업 메뉴·컨텍스트 메뉴·명령어 검색), Breadcrumb "홈 (custom link)"→"홈", Toggle "Disabled off/on"→"Off/On", SW 뷰어 속성 "일반 정보 (General)"·"기하 속성 (Geometry)"의 괄호 영문, Date Picker Label "기본 형식 · " 접두어, 차트 설명 줄(H2 반복만 있는 Line 6·Area 곡선 6·Donut 4·Radar 4 전체 제거, Bar Horizontal "· 가로 막대"·Radial 내부 ID 접두어만 제거, 정보가 섞인 Pie·Area Stacked/Expanded는 정렬 때문에 유지), SW 속성창 비교 figcaption 4개(상태명은 figure `aria-label`). +- 기타: 라디오 그룹 aria-label이 내부 id(`sc-d`)로 읽히던 문제 → "옵션 선택", slider 소스 공백 오타, alert-dialog 주석 정정. 테스트 `packages/sw/tests/property-panel-comparison.browser.mjs`: figcaption 대신 figure aria-label도 읽고, 1열/3열 기준을 현재 CSS(727 / 1065px, 좌우 여백 30px)에 맞춤. +- Selection Controls H2("Selection Controls")와 Step Header H2는 섹션 구조(제목 위계)에 필요해 유지. Story ID 388개·Props·컴포넌트 불변. 문서: button.md·toggle.md·combobox.md·date-picker.md 해당 문장. + +## 2026-11-13 — 이동 경로(Breadcrumb) Story: Custom 명칭·Variants 1/3열·Usage 전체 너비 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- `src/components/breadcrumb.stories.tsx`의 대표 `All`만 수정. 예제 이름 `Custom link · separator` → `Custom`(id `custom` 유지). Variants 배치를 `auto-fill`(최소 240px) → `grid-cols-1` + `min-[1000px]:grid-cols-3`(Accordion·Collapsible과 같은 1 → 3열 패턴, 2 + 1 줄 배치 제거). Usage(Long path)의 `max-w-[900px]` 제거 → 섹션 전체 너비, `BreadcrumbList` `flex-wrap`으로 자연 줄바꿈. 공통 `StorySection` / `StateBoard` 재사용, 페이지 전용 CSS 없음. Story ID·예제 수·verification Story·Breadcrumb 컴포넌트·Props·Theme·토큰 불변. `docs/components/breadcrumb.md` §9 대표 Story 설명 갱신. +- 검증(6027 임시 서버 `storybook-verify`): iframe 1400 = 3열(각 425px) / 1024 = 3열(각 300px), 760 / 390 = 1열. 세 예제 모두 제목 → Breadcrumb 12px, 시작 위치 같음, 섹션 간격 28px. Long path는 1400·1024에서 1줄, 760에서 2줄, 390에서 3줄이며 넘침·문서 가로 스크롤 없음. 마지막 경로 700·`rgb(33,33,33)` 유지. Ellipsis Dropdown은 열림(`문서`·`설정`) → Esc 닫힘 → 트리거로 Focus 복귀, 링크 href·`/` 구분자 유지, 콘솔 오류 없음. `tsc -b` 통과. + +## 2026-11-13 — 탭 Story: Variants 2열·Behavior(Overflow) 분리·Usage 반응형 2열 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- `src/components/tabs.stories.tsx`의 대표 `All`만 재배치. Variants = Primary / Secondary 균등 2열, 새 H2 **Behavior** = 기존 Overflow 예제(20개 탭, 내용 불변)를 왼쪽 열에, Usage = 기존 두 예제(Card 흰 배경·페이지 배경·`max-w-lg`·입력 요소 불변) 균등 2열. `ThreeColumns` 제거, `TwoColumns`를 `@container` + `@min-[720px]` 2열(24px 간격)로 변경 — 기준은 섹션 본문 실제 너비(공통 board 들여쓰기 16px 제외). 공통 `StorySection` / `StateBoard` 재사용, 페이지 전용 CSS 없음. Story ID·`data-overview-state`·verification Story·Tabs 컴포넌트·Props·Theme·토큰 불변. meta 설명 문구와 `docs/components/tabs.md` 대표 Story 설명 갱신. +- 검증(6027 임시 서버 `storybook-verify`, 6024는 다른 세션 서버로 `storybook-stories.js` 404): iframe 1400(컨테이너 1309) / 1024(933) = 2열(각 642 / 454px, 제목·탭 시작점 동일), 760(685) / 390(346) = 1열, 문서 가로 스크롤 없음. 탭 클릭 선택·패널 전환(Primary·Secondary·Overflow), Overflow 다음 버튼 `scrollLeft` 증가·이전 버튼 활성화·선택 유지. `tsc -p tsconfig.app.json --noEmit` 통과. + +## 2026-11-13 — 선택 컨트롤 상태 비교 매트릭스 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- `src/components/selection-controls.stories.tsx`만 재작성(+ `checkbox-examples.tsx`의 `Controlled` export). 반복 H2 6개("Checkbox · States" 등) → H2 `Selection Controls`(상태 매트릭스) + H2 `Variants`(Checkbox Indeterminate, Radio Group Vertical/Horizontal; Switch는 별도 Variant 없음). +- 매트릭스: 행 Default / Hover / Focus / Disabled / Validation, 열 State / Checkbox / Radio Group / Switch. Radio Disabled는 캡션 `항목`·`그룹`으로 구분, Switch Validation은 `—`(상태 없음). Radio Error 시안은 선택 없음(`defaultValue=""`, 사용자 요청 — Error 행에 초록 선택 표시 없음). 컴포넌트·Props·Theme CSS 불변, Hover는 기존 `data-sb-hover`, Focus는 기존처럼 Checkbox만 play에서 자동 Focus. +- 반응형: DOM 1회 렌더(컴포넌트 순서) + 컨테이너 쿼리(`@container`, 미리보기 Inner 실제 너비). Inner ≥ 720px이면 각 컴포넌트 블록이 `display: contents`로 4열 grid에 배치(열 `max-content` + 남는 폭 1fr 균등, 행 구분 `border-border` 1px, 셀 12px 상하·24px 열 간격). 미만이면 Checkbox → Radio Group → Switch, 각 Default → Validation 세로 그룹(상태명 12/16/400). 4열 콘텐츠 실측 합계 692px(State 92 + 232 + 217 + 151) → 여유 28px로 720px. +- 검증(6026, 6024는 기존 `/index.json` 500 지속): iframe 1400·1024 = 4열, 760·390 = 세로 그룹, 문서 가로 스크롤·잘림·화면 밖 요소 0. 인스턴스 Checkbox 9·RadioGroup 8·Switch 7(중복 렌더 없음), `data-overview-state` 19개, Hover 시각(테두리 색) 유지, Manager 1024 창(LNB 180)에서도 4열. `tsc -p tsconfig.app.json --noEmit` 통과. +- 남은 정리 후보: `*Sections`의 `titlePrefix`·`idPrefix`·`gridClassName`는 이제 사용처 없음(숨김 개별 페이지는 미사용) — 범위 밖이라 유지. + +## 2026-11-13 — 통합 페이지 디자인 정합성: 버튼·Toggle Button 규칙 통일, 선택 컨트롤 공통 그리드 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- 버튼 페이지 Toggle Button 재구성(같은 날 후속, 아래 `flat` 기록을 대체): `flat`이면 `ButtonPageToggleSection` — 독립 H2 "Toggle Button" 한 섹션(버튼 열과 36px: 섹션 간격 28px + `pt-2`), 그룹 H3 Variants / States(Off / On, Disabled) · Groups(단독, 단일 선택, 다중 선택 — 3차: 버튼 페이지만 `groupSpecimens(idPrefix, true)`로 "연결 그룹 · " 접두어 생략, 숨김 Toggle 페이지 라벨 유지) · Sizes(Small/Default/Large). 버튼과 통일(같은 날 2차): 1400px 이상 같은 너비 3열(버튼 열과 같은 x), 미만 1열·그룹 간 24px. 각 행은 States 매트릭스 형식(왼쪽 라벨 14/20/500 → 8px → 컨트롤, 행 간 8px, subgrid로 그룹 안 컨트롤 시작선 정렬). `ToggleSections`에 `stacked` 옵션 — 버튼 페이지가 `MatrixFit` 안의 `ToggleButtonSection`에서 넘겨, 매트릭스가 세로 배치되면(390px) Toggle도 라벨 위 12/16/400 → 4px → 컨트롤, 행 간 12px로 함께 바뀐다. 반복 제목 `Toggle Button · …` 제거, 예시 ID(`toggle-offon` 등) 유지. 숨김 Toggle 페이지 경로(4섹션·StateBoard·Sizes 들여쓰기)는 그대로이며 `groupSpecimens`·`SizeRow`만 공유. 검증 1440/1400/1399/1100/1099/1024/768/390 가로 스크롤·잘림·줄바꿈 0, 상호작용 정상, `tsc -p tsconfig.app.json` 통과. +- (이전 기록) 버튼 페이지 Toggle Button 섹션: `ToggleSections`에 `flat` 옵션(버튼 페이지만). H3 그룹 라벨을 버튼 GroupBlock 규칙(들여쓰기 0, H3 → 예시 8px)으로 그리고 Sizes 들여쓰기도 뺀다. 1400px 이상에서는 Variants·States가 버튼 3열 트랙의 1·2열을 써서 단독/연결 그룹·Sizes와 함께 Brand / Standard Solid / Standard Outline 열에 맞는다(1000~1399px는 기존 324:234 비율 2열, 999px 이하 1열). 섹션 제목 `Toggle Button · 단독 · 연결 그룹` → `Toggle Button · 단독/연결 그룹`. 숨김 Toggle 페이지는 기존 StateBoard(16px·12px)·제목 그대로. +- 버튼 상태 행렬 세로 배치(좁은 화면)의 상태 이름만 12/16/400(`text-xs font-normal`, 보조색)으로 H3 "States"(14/20/500)와 구분. 공통 H2/H3 스타일은 불변. +- (이전 기록, 위 상태 매트릭스로 대체) 선택 컨트롤: `CheckboxSections`·`RadioGroupSections`·`SwitchSections`에 `gridClassName` 옵션. 통합 페이지만 공통 트랙(1000px 이상 4열, 미만은 최소 220px 자동 열 → 999px 3열·768px 2열·390px 1열)을 넘겨 모든 섹션의 예시 열이 맞는다. Radio Horizontal은 1000px 이상 2칸·미만 한 줄 전체를 써 한 줄 유지. 숨김 Checkbox(3열)·Radio Group(2/4/4열)·Switch(5열) 페이지는 그대로. +- 주의: Tailwind 임의값 class 바로 뒤에 `${…}`를 붙이면(공백 없이) 소스 스캔이 그 class를 놓친다. 이번 작업 중 Toggle Variants/States 2열이 사라졌다가 공백으로 분리해 복구. +- 검증(6026, 다른 세션 Storybook이 6025 사용 중): 버튼 1440/1400/1399/1024/768/390, 선택 컨트롤 1440/1024/1000/999/768/390 페이지·내부 가로 스크롤·잘림 0, 예시 수 불변(11/19), 상호작용·Focus·Disabled 정상, 숨김 페이지 레이아웃 불변, `tsc -p tsconfig.app.json` 통과. 인벤토리 재생성 결과 변화 없음(KEEP 73 / HIDE 315). +- 6024: 계속 `/index.json` 500(`center-spatial.stories.tsx`를 MDX 문서 단계에서 파싱한 캐시 오류). 다른 세션 서버라 재시작하지 않음 — 그 세션에서 재시작 필요. + +## 2026-11-13 — Storybook 유사 컴포넌트 6개 그룹 통합·메뉴명 2건 (브랜치 `style/storybook-ui-refinements`, 미커밋) + +- 방식: 2026-11-12 대화상자·팝업 메뉴 통합과 같다. 흡수되는 컴포넌트의 예제를 `src/components/-examples.tsx`(Storybook 전용)로 옮기고 `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 › 뷰어 › 화면 너비: 숨김 콜백 기록 `
- + {['navy', 'teal', 'orange', 'violet'].map((palette) => ( - + - + - + {CONTENT_STATES.map((state) => (
{state.label} diff --git a/packages/center-graphic/src/components/CenterSpatialInformation.tsx b/packages/center-graphic/src/components/CenterSpatialInformation.tsx index f83271f..b165468 100644 --- a/packages/center-graphic/src/components/CenterSpatialInformation.tsx +++ b/packages/center-graphic/src/components/CenterSpatialInformation.tsx @@ -452,11 +452,6 @@ export function CenterSpatialInformationReferenceDemo() {
@@ -489,9 +483,7 @@ export function CenterSpatialInformationReferenceDemo() {
diff --git a/packages/center-graphic/src/components/FacilityBatch.tsx b/packages/center-graphic/src/components/FacilityBatch.tsx index 1d5238e..035befe 100644 --- a/packages/center-graphic/src/components/FacilityBatch.tsx +++ b/packages/center-graphic/src/components/FacilityBatch.tsx @@ -422,8 +422,8 @@ function FacilityReferenceDemo({ config, alternateName }: { config: FacilityConf return (
- - + +
{alternateName ? ( @@ -435,14 +435,14 @@ function FacilityReferenceDemo({ config, alternateName }: { config: FacilityConf ) : null} - +
- +
{widthNames.map((name, index) => (
{['SHORT', 'MEDIUM', 'LONG'][index]}
@@ -473,7 +473,7 @@ export function RoadsideReferenceDemo() { return (
- +
{tests.map((test) => (
@@ -484,7 +484,7 @@ export function RoadsideReferenceDemo() {
- +
{tests.map((test) => (
@@ -495,11 +495,11 @@ export function RoadsideReferenceDemo() {
- + - +
diff --git a/packages/center-graphic/src/components/TunnelFacility.tsx b/packages/center-graphic/src/components/TunnelFacility.tsx index 1bbd7f3..b5586fb 100644 --- a/packages/center-graphic/src/components/TunnelFacility.tsx +++ b/packages/center-graphic/src/components/TunnelFacility.tsx @@ -178,15 +178,15 @@ export function TunnelReferenceDemo() { return (
- + - +
- +
diff --git a/packages/chart/README.md b/packages/chart/README.md index 7135000..5dbf781 100644 --- a/packages/chart/README.md +++ b/packages/chart/README.md @@ -61,7 +61,7 @@ type HMDonutDatum = { id: string; label: string; value: number; color?: string } - 색상을 지정하지 않으면 `--hm-chart-1`~`5`(HM 그린·브라운·웜 뉴트럴 팔레트 별칭)를 순서대로 사용합니다. 다른 색은 `color`에 CSS 색상/토큰을 전달합니다. - 범례 라벨은 짧게 유지합니다. Bar·Line·Radar·Area의 범례는 한 줄로 두며, 직접 `flex-wrap`으로 줄바꿈시키면 Recharts가 범례 높이를 과대 측정해 차트 아래에 빈 공간이 생길 수 있습니다. Donut·Pie·Stacked Pie는 컴포넌트 내부에서 줄바꿈 범례를 쓰도록 구성되어 있고, Radial 계열은 범례를 차트 박스 밖 아래에 따로 놓습니다. - 모든 차트의 `label`은 필수 접근성 이름(`role="group"`의 `aria-label`)입니다. -- 차트는 부모 폭을 따라 줄어듭니다(`w-full`, 높이 `h-64` → `sm:h-80`). `className`으로 높이를 바꿀 수 있습니다. 부모에 `min-width: 0`이 없으면 flex·grid 안에서 넘칠 수 있습니다. +- 차트는 부모 폭을 따라 줄어듭니다(`w-full`, 높이 `h-64` → `sm:h-80`; Donut·Pie·Radar는 `h-72` → `sm:h-80`, Radial은 `300px`). `className`으로 높이를 바꿀 수 있습니다. 부모에 `min-width: 0`이 없으면 flex·grid 안에서 넘칠 수 있습니다. ## 4. 렌더링 diff --git a/packages/sw/tests/property-panel-comparison.browser.mjs b/packages/sw/tests/property-panel-comparison.browser.mjs index ca4006f..a51b920 100644 --- a/packages/sw/tests/property-panel-comparison.browser.mjs +++ b/packages/sw/tests/property-panel-comparison.browser.mjs @@ -6,7 +6,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { execFileSync } from 'node:child_process' import path from 'node:path' import http from 'node:http' -import { swStoryEntries, retiredPropertyPanelIds, retiredUnusedDemoIds, approvedAddedStoryIds, headerInventoryIssues } from './storybook-inventory.mjs' +import { swStoryEntries, retiredPropertyPanelIds, headerInventoryIssues, swScopeIssues } from './storybook-inventory.mjs' import { loadPlaywright } from './playwright-runtime.mjs' // Property panel comparison consolidation (2026-10-19). @@ -39,7 +39,7 @@ const geometry = () => page.evaluate(() => { const r = e => { const b = e.getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height, right: b.right, bottom: b.bottom } } const scroller = document.querySelector('[data-compare-scroll]') return { - cells: [...document.querySelectorAll('[data-compare]')].map(c => ({ id: c.dataset.compare, cell: r(c), panel: r(c.querySelector('.hm-sw-property')), caption: c.querySelector('figcaption strong')?.textContent })), + cells: [...document.querySelectorAll('[data-compare]')].map(c => ({ id: c.dataset.compare, cell: r(c), panel: r(c.querySelector('.hm-sw-property')), caption: c.querySelector('figcaption strong')?.textContent ?? c.getAttribute('aria-label') })), scroller: scroller && { ...r(scroller), scrollW: scroller.scrollWidth, clientW: scroller.clientWidth, scrollH: scroller.scrollHeight, clientH: scroller.clientHeight }, doc: { scrollW: document.documentElement.scrollWidth, clientW: document.documentElement.clientWidth, scrollH: document.documentElement.scrollHeight, clientH: document.documentElement.clientHeight }, } @@ -70,12 +70,15 @@ try { // approved additions, the verification tags and the Popup move). const recorded = JSON.parse(execFileSync('git', ['show', 'c8e5fbeebc26fba92998fc5ddcd28c6c110e452d:docs/sw/property-panel-comparison-qa/verify/result.json'], { encoding: 'utf8', maxBuffer: 1 << 26 })).data.inventory const now = headerInventoryIssues(before.sw, index) + // 2026-11-13 (user request): the former whole-Storybook count (338 - retired + approved SW additions) failed whenever a + // Story outside SW was added; the SW Story set is checked instead (swScopeIssues: no unapproved SW addition, deletion or ID change). + const swScope = swScopeIssues(before.sw, index) report.data.inventory.recorded = { total: recorded.total, sw: recorded.sw } check('inventory: 24 -> 19 SW Stories, 343 -> 338 total, only 5 approved retirements (recorded at c8e5fbe); now only approved later changes', before.sw.length === 24 && recorded.sw === 19 && before.total === 343 && recorded.total === 338 && JSON.stringify(recorded.ids.map(([id]) => id).sort()) === JSON.stringify(expected.map(e => e.id).sort()) && retiredPropertyPanelIds.every(id => !index.entries[id]) && - stories.length === 338 - retiredUnusedDemoIds.length + approvedAddedStoryIds.length && now.unexpected.length === 0 && now.missing.length === 0 && now.retiredPresent.length === 0, { before: [before.total, before.sw.length], recorded: [recorded.total, recorded.sw], after: [stories.length, sw.length], ...now }) + swScope.ok && now.unexpected.length === 0 && now.missing.length === 0 && now.retiredPresent.length === 0, { before: [before.total, before.sw.length], recorded: [recorded.total, recorded.sw], after: [stories.length, sw.length], ...now, swScope }) check('inventory: retained SW Story IDs, names, titles, tags unchanged (recorded at c8e5fbe); now only the approved tags and Popup move', JSON.stringify(recorded.ids) === JSON.stringify(expected.map(e => [e.id, e.name])) && now.changed.length === 0, { retained: sw.length, changed: now.changed }) - check('inventory: compare Story keeps its ID and name', index.entries['sw-header--property-panel-compare']?.name === 'Property Panel') + check('inventory: compare Story keeps its ID and name', index.entries['sw-header--property-panel-compare']?.name === '속성창') check('inventory: Dropdown Story keeps ID / name / title', index.entries['sw-header--composed-controls']?.name === 'Dropdown' && index.entries['sw-header--composed-controls']?.title === 'SW/Header') // ---- Layout at four widths @@ -93,7 +96,8 @@ try { // 2026-10-26 (user request): from 1080px, 3 columns — none/all stacked in column 1, circle and expanded beside them. // 2026-11-05: the Story page Inner keeps 60px on each side (story-page.css), so the steps moved to 787px / 1125px // (sw-stories.css .sw-story-compare--grid). It keeps 16px up to 480px (growing to 60px at 768px), so 390px has 358px. - const oneColumn = width < 787, threeColumns = width >= 1125 + // 2026-11-13: the side margin maximum is 30px (story-page.css), so sw-stories.css moved the steps to 727px / 1065px. + const oneColumn = width < 727, threeColumns = width >= 1065 if (threeColumns) { const gaps = { stack: b.cell.y - a.cell.bottom, col1: c.cell.x - Math.max(a.cell.right, b.cell.right), col2: d.cell.x - c.cell.right } report.data[`gaps${width}`] = gaps @@ -141,21 +145,31 @@ try { report.data[`drop-${id}`] = info check(`Dropdown ${id}: opens, has items, fully inside the viewport`, info.items > 0 && info.x >= 0 && info.y >= 0 && info.right <= info.vw + 0.5 && info.bottom <= info.vh + 0.5, info) await page.screenshot({ path: path.join(out, `dropdown-${id}.png`) }) - if (id === 'expanded') { - // 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 }) - await page.waitForTimeout(150) - const after = await page.evaluate(() => { - const d = document.querySelector('.hm-sw-property__drop')?.getBoundingClientRect() - const f = document.querySelector('[data-compare="expanded"] [data-property-id="layer"] [aria-expanded="true"]')?.getBoundingClientRect() - return d && f ? { dTop: d.top, dBottom: d.bottom, fTop: f.top, fBottom: f.bottom } : null - }) - report.data.dropScroll = { moved, before: info, after } - check('Dropdown follows the page scroll (stays attached to its field)', moved > 0 && after && (Math.abs(after.dTop - after.fBottom) < 1.5 || Math.abs(after.dBottom - after.fTop) < 1.5), report.data.dropScroll) - } await page.keyboard.press('Escape') check(`Dropdown ${id}: Escape closes and returns focus to its field`, await page.locator('.hm-sw-property__drop').count() === 0 && await trigger.evaluate(e => document.activeElement === e || e.contains(document.activeElement))) } + // Scroll the page while open (2026-10-22: no Inner scroller): the fixed overlay must stay attached to its field. + // 2026-11-13: at 1440 × 900 the page (3-column layout since 2026-10-26) fits the viewport and cannot scroll, and the Story + // preview sets `html { scroll-behavior: smooth }` (src/hm-theme.css), so scrollY read right after scrollBy stayed 0. The + // check runs at 1440 × 600, where the page scrolls, with an instant scroll; the viewport and scroll position are restored. + { + const size = page.viewportSize() + await page.setViewportSize({ width: size.width, height: 600 }) + await page.evaluate(() => scrollTo({ top: 0, behavior: 'instant' })) + const { trigger, info } = await openPicker('expanded') + const moved = await page.evaluate(() => { const t = scrollY; scrollBy({ top: 120, behavior: 'instant' }); return scrollY - t }) + await page.waitForTimeout(150) + const after = await page.evaluate(() => { + const d = document.querySelector('.hm-sw-property__drop')?.getBoundingClientRect() + const f = document.querySelector('[data-compare="expanded"] [data-property-id="layer"] [aria-expanded="true"]')?.getBoundingClientRect() + return d && f ? { dTop: d.top, dBottom: d.bottom, fTop: f.top, fBottom: f.bottom } : null + }) + report.data.dropScroll = { viewport: { width: size.width, height: 600 }, moved, before: info, after } + check('Dropdown follows the page scroll (stays attached to its field)', moved > 0 && after && (Math.abs(after.dTop - after.fBottom) < 1.5 || Math.abs(after.dBottom - after.fTop) < 1.5), report.data.dropScroll) + await page.keyboard.press('Escape') + await trigger.evaluate(() => scrollTo({ top: 0, behavior: 'instant' })) + await page.setViewportSize(size) + } // Select a layer value: callback updates only that panel. { const { drop } = await openPicker('all') diff --git a/packages/sw/tests/storybook-inventory.mjs b/packages/sw/tests/storybook-inventory.mjs index 2f24e12..b42cb19 100644 --- a/packages/sw/tests/storybook-inventory.mjs +++ b/packages/sw/tests/storybook-inventory.mjs @@ -106,11 +106,16 @@ export const menuCleanupHiddenIds = ['sw-header--interactive-states', 'sw-header 'sw-viewer--compare-with-sw-header', // 2026-10-22 (user request): SW/Popup keeps only Property Panel in the sidebar. 'sw-popup--custom-content', 'sw-popup--multiple-popups'] -export const renamedEntries = { 'sw-header--overview': 'SW Header', 'sw-footer--full-footer': 'SW Footer', - 'sw-header--logo-slot': 'Logo', // 2026-10-22 (user request): sidebar "Logo", page title "SW Header — 로고" - 'sw-header--property-panel-compare': 'Property Panel', // 2026-10-22 (user request): was "Property Panel · 4개 비교" - 'sw-footer--responsive': 'Responsive More', 'sw-viewer--content-variants': 'Settings Popup', - 'sw-viewer--responsive': 'Responsive More' } +// 2026-11-13 (user request): the expected names follow the current approved Story names (Korean menu names); IDs unchanged. +export const renamedEntries = { 'sw-header--overview': 'SW Header', 'sw-footer--full-footer': 'SW 명령어바', + 'sw-header--logo-slot': '로고', // 2026-10-22 (user request): sidebar "Logo", page title "SW Header — 로고"; now 로고 + 'sw-header--property-panel-compare': '속성창', // 2026-10-22 (user request): was "Property Panel · 4개 비교", then Property Panel + 'sw-header--responsive-more': '화면 너비', + 'sw-footer--responsive': '화면 너비', 'sw-viewer--content-variants': '설정창', + 'sw-viewer--responsive': '화면 너비' } +// Approved commit 2378663 (2026-11-09, "localize sidebar menus and hide unused stories"): Logo Slot left the default sidebar +// with the `internal` tag (Story ID and direct URL kept). Only that tag is added to the recorded entry. +export const internalTaggedIds = ['sw-header--logo-slot'] // Every approved SW Story ID added after the unused-demo removal, as the current index lists them. export const approvedAddedStoryIds = [...integratedStoryIds.filter(id => !retiredToolIds.includes(id)), ...homeMenuSidebarIds, ...Object.values(movedHomeMenuIds).filter(id => !retiredHomeMenuCompareIds.includes(id)), ...footerStoryIds, ...viewerStoryIds, ...themeStoryIds] @@ -120,6 +125,7 @@ export const withApprovedOrder = entries => [...entries.filter(entry => entry.id // An earlier index entry with the approved tag / Popup move applied (2b0e1a2; the moved Story has no component path). export const withApprovedEntryChanges = entry => { if (renamedEntries[entry.id]) entry = { ...entry, name: renamedEntries[entry.id] } + if (internalTaggedIds.includes(entry.id)) entry = { ...entry, tags: [...entry.tags, 'internal'] } if (verificationTaggedIds.includes(entry.id)) return { ...entry, tags: [...entry.tags, 'verification'] } if (!movedToPopupIds.includes(entry.id)) return entry const { componentPath, ...rest } = entry @@ -145,6 +151,20 @@ export const movedHeaderDeclarations = { PropertyPanelCompare: ['src/sw/sw-prope export const laterRetiredHeaderIds = [...retiredPropertyPanelIds, ...retiredUnusedDemoIds] // The approved additions that sit under SW/Header. export const approvedAddedHeaderIds = approvedAddedStoryIds.filter(id => id.startsWith('sw-header--')) +// User-approved SW/Step Header Stories (adbb034, 2026-11-03; navigation labels b67a3ec), a SW group added after the lists above. +export const stepHeaderStoryIds = ['process-header', 'responsive', 'split-open', 'multi-step', 'fixed-widths', 'process-bar', + 'multi-step-many-groups', 'multi-step-many-steps', 'states', 'theme-override'].map(id => `sw-step-header--${id}`) +// 2026-11-13 (user request): the SW Story set, separate from the whole Storybook count. Every Story whose title starts with +// "SW/" must be either a retained SW/Header Story of the earlier list (approved changes applied) or an approved addition, and +// every one of those must exist: `unexpected` catches an unapproved SW addition or an ID change, `missing` a deletion or an +// ID change. Stories outside SW may be added or removed without affecting it. +export const swScopeIssues = (entries, index) => { + const retained = retainedInventory(entries).filter(entry => !laterRetiredHeaderIds.includes(entry.id)).map(withApprovedEntryChanges).map(entry => entry.id) + const approved = [...new Set([...retained, ...approvedAddedStoryIds, ...approvedAddedHeaderIds, ...stepHeaderStoryIds])] + const current = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('SW/')).map(entry => entry.id) + const issues = { actual: current.length, expected: approved.length, unexpected: current.filter(id => !approved.includes(id)), missing: approved.filter(id => !current.includes(id)) } + return { ...issues, ok: issues.actual === issues.expected && issues.unexpected.length === 0 && issues.missing.length === 0 } +} // An earlier SW/Header entry list against today's index: `changed` (ID, name, title or tags differ from the earlier entry // with the approved changes applied), `unexpected` (SW/Header Stories neither retained nor approved), `missing` (approved // additions not under SW/Header) and `retiredPresent` (an approved retirement back in the index). diff --git a/packages/sw/tests/storybook-unused-removal.browser.mjs b/packages/sw/tests/storybook-unused-removal.browser.mjs index 7159354..37b1d6d 100644 --- a/packages/sw/tests/storybook-unused-removal.browser.mjs +++ b/packages/sw/tests/storybook-unused-removal.browser.mjs @@ -44,7 +44,7 @@ const keys = Object.keys(afterIndex.entries), firstSW = keys.findIndex(id => id. const swOrder = { previous: keys[firstSW - 1], afterCenterGraphic: afterIndex.entries[keys[firstSW - 1]]?.title.startsWith('Center Graphic/') && !keys.slice(firstSW + 1).some(id => afterIndex.entries[id].title.startsWith('Center Graphic/')), contiguous: keys.filter(id => id.startsWith('sw-')).every((id, i) => keys[firstSW + i] === id) } check('inventory: every non-removed index entry identical (docs and other packages included) except the approved SW changes', outsideSW(others(beforeIndex)) === outsideSW(others(afterIndex)) && JSON.stringify(others(beforeIndex).filter(e => e.id.startsWith('sw-')).map(withApprovedEntryChanges)) === JSON.stringify(others(beforeIndex).filter(e => e.id.startsWith('sw-')).map(e => afterIndex.entries[e.id])) && swOrder.afterCenterGraphic && swOrder.contiguous, swOrder) check('inventory: Dropdown keeps sw-header--composed-controls / Dropdown / SW/Header', afterIndex.entries['sw-header--composed-controls']?.name === 'Dropdown' && afterIndex.entries['sw-header--composed-controls']?.title === 'SW/Header') -check('inventory: comparison keeps its ID and name', afterIndex.entries['sw-header--property-panel-compare']?.name === 'Property Panel') +check('inventory: comparison keeps its ID and name', afterIndex.entries['sw-header--property-panel-compare']?.name === '속성창') // ---- Source: retained Story declarations byte-identical; product, AI template and other packages untouched. const sourcePath = 'src/sw/sw-header.stories.tsx' diff --git a/packages/sw/tests/viewer-logo-document-menu.browser.mjs b/packages/sw/tests/viewer-logo-document-menu.browser.mjs index 43fe652..a62064b 100644 --- a/packages/sw/tests/viewer-logo-document-menu.browser.mjs +++ b/packages/sw/tests/viewer-logo-document-menu.browser.mjs @@ -89,8 +89,12 @@ let reference, viewerMenu, moreMenu, refTrigger const menu = header.getByRole('menu', { name: '추가 도구' }) reference = await face(menu, MORE_ITEM) refTrigger = await more.evaluate(element => { const s = getComputedStyle(element); return `${s.borderTopWidth} ${s.borderTopStyle} ${s.borderTopColor} | ${s.borderTopLeftRadius} | ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor} | ${s.boxShadow}` }) - const responsive = await newPage(1920, 1500) + // 2026-11-13: 2500px wide (like viewer.browser.mjs), so the 1920 row, shown only on a page of 1980px or more, is visible too. + const responsive = await newPage(2500, 1500) await open(responsive, 'sw-viewer--responsive') + // 2026-11-13: since 2026-10-26 More shows only once even the narrowest tabs do not fit (below about 520px), so the 520 row + // is narrowed in place to 400px first (as viewer.browser.mjs does) to open the same More menu. + await responsive.evaluate(async () => { document.querySelector('[data-width="520"]').style.maxWidth = '400px'; await new Promise(resolve => setTimeout(resolve, 400)) }) await responsive.locator('[data-width="520"] .hm-sw-viewer-header').getByRole('button', { name: '추가 도구' }).click() const more520 = responsive.getByRole('menu', { name: '추가 도구' }) moreMenu = await face(more520, VIEWER_ITEM, '.hm-sw-viewer-menu__label', '.hm-sw-viewer-menu__separator', '.hm-sw-viewer-menu__icon', '.hm-sw-viewer-menu__text') @@ -129,12 +133,17 @@ let reference, viewerMenu, moreMenu, refTrigger const layout = await header.evaluate(element => { const button = element.querySelector('[data-document-open]'), wrapper = button.parentElement return { surface: button.classList.contains('hm-sw-header__document-button') && wrapper.matches('.hm-sw-header__document[data-tool]'), afterLogo: wrapper.previousElementSibling?.classList.contains('hm-sw-viewer-header__brand'), - beforeName: wrapper.nextElementSibling?.classList.contains('hm-sw-viewer-header__document'), label: button.querySelector('.hm-sw-viewer-header__open-label')?.textContent, icons: button.querySelectorAll('.hm-sw-viewer-header__open-icon svg').length } + beforeName: wrapper.nextElementSibling?.classList.contains('hm-sw-viewer-header__document'), label: button.querySelector('.hm-sw-viewer-header__open-label')?.textContent, + // 2026-11-13: the folder icon carries the viewer icon outline (SWViewerIcon, caf4c32 2026-10-21): one icon (glyph) SVG and + // one outline copy behind it, nothing else. + icons: button.querySelectorAll('.hm-sw-viewer-header__open-icon svg').length, glyphs: button.querySelectorAll('.hm-sw-viewer-header__open-icon .hm-sw-viewer-icon__glyph svg').length, + outlines: button.querySelectorAll('.hm-sw-viewer-header__open-icon .hm-sw-viewer-icon__outline svg').length } }) - check('file open button after the logo: SWHeader document surface, icon + 파일 열기', layout.surface && layout.afterLogo && layout.beforeName && layout.label === '파일 열기' && layout.icons === 1, layout) - const iconBox = await openButton.locator('.hm-sw-viewer-header__open-icon').evaluate(element => { const s = getComputedStyle(element), r = element.getBoundingClientRect(); return { style: `${s.borderTopWidth} ${s.borderTopStyle} ${s.borderTopColor} | ${s.borderTopLeftRadius} | ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor} | ${s.boxShadow}`, size: [Math.round(r.width), Math.round(r.height)] } }) - // 2026-10-21 user request: the icon has no border and no face (transparent); 34px slot kept. - check('file open icon: no border, transparent, no shadow; 34px slot', iconBox.style.startsWith('0px none') && iconBox.style.includes('rgba(0, 0, 0, 0)') && iconBox.style.endsWith('none') && iconBox.size.join() === '34,34', iconBox) + check('file open button after the logo: SWHeader document surface, icon (1 glyph + 1 outline SVG) + 파일 열기', layout.surface && layout.afterLogo && layout.beforeName && layout.label === '파일 열기' && layout.glyphs === 1 && layout.outlines === 1 && layout.icons === 2, layout) + const iconBox = await openButton.locator('.hm-sw-viewer-header__open-icon').evaluate(element => { const s = getComputedStyle(element), r = element.getBoundingClientRect(), b = element.closest('[data-document-open]').getBoundingClientRect(); return { style: `${s.borderTopWidth} ${s.borderTopStyle} ${s.borderTopColor} | ${s.borderTopLeftRadius} | ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor} | ${s.boxShadow}`, size: [Math.round(r.width), Math.round(r.height)], button: [Math.round(b.width), Math.round(b.height)] } }) + // 2026-10-21 user request: the icon has no border and no face (transparent). 2026-11-13: the icon area is the 16 × 16 icon + // itself with no margin (0840d76, DESIGN_RULES --hm-sw-viewer-icon-size); the click area is the button, at least 34px each way. + check('file open icon: no border, transparent, no shadow; 16 × 16 icon, button click area ≥ 34px', iconBox.style.startsWith('0px none') && iconBox.style.includes('rgba(0, 0, 0, 0)') && iconBox.style.endsWith('none') && iconBox.size.join() === '16,16' && iconBox.button.every(side => side >= 34), iconBox) // File-name area (.hm-sw-viewer-header__document): no left padding, right after the file open button; the file-name // button inside keeps its own padding (user decision 2026-10-21). const nameStart = await header.evaluate(element => { const d = element.querySelector('.hm-sw-viewer-header__document'); return [getComputedStyle(d).paddingLeft, Math.round(d.getBoundingClientRect().left - element.querySelector('[data-document-open]').getBoundingClientRect().right)] }) @@ -179,18 +188,21 @@ let reference, viewerMenu, moreMenu, refTrigger const openState = width => header.evaluate(async (element, width) => { element.style.width = `${width}px` await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))) - const button = element.querySelector('[data-document-open]'), icon = button.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect() - return { width, label: !!button.querySelector('.hm-sw-viewer-header__open-label'), aria: button.getAttribute('aria-label'), title: button.title, icon: [Math.round(icon.width), Math.round(icon.height)], overflow: element.scrollWidth - element.clientWidth } + const button = element.querySelector('[data-document-open]'), icon = button.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect(), area = button.getBoundingClientRect() + return { width, label: !!button.querySelector('.hm-sw-viewer-header__open-label'), aria: button.getAttribute('aria-label'), title: button.title, icon: [Math.round(icon.width), Math.round(icon.height)], button: [Math.round(area.width), Math.round(area.height)], overflow: element.scrollWidth - element.clientWidth } }, width) const sweep = [] for (let width = 1440; width >= 400; width -= 20) sweep.push(await openState(width)) const back = [] for (let width = 400; width <= 1440; width += 20) back.push(await openState(width)) const firstIcon = sweep.find(item => !item.label) - check('1440: icon + 파일 열기; narrower: icon only (tooltip / name 파일 열기, 34px icon slot kept)', sweep[0].label && firstIcon - && sweep.filter(item => !item.label).every(item => item.aria === '파일 열기' && item.title === '파일 열기' && item.icon.join() === '34,34'), { first: sweep[0], firstIcon }) + // 2026-11-13: at every width the icon stays the 16 × 16 icon area (0840d76) and the button keeps a click area of at least 34px. + check('1440: icon + 파일 열기; narrower: icon only (tooltip / name 파일 열기); 16 × 16 icon and ≥ 34px click area at every width', sweep[0].label && firstIcon + && sweep.filter(item => !item.label).every(item => item.aria === '파일 열기' && item.title === '파일 열기') + && [...sweep, ...back].every(item => item.icon.join() === '16,16' && item.button.every(side => side >= 34)), { first: sweep[0], firstIcon, icons: [...new Set([...sweep, ...back].map(item => item.icon.join()))], minButton: [Math.min(...sweep.map(item => item.button[0])), Math.min(...sweep.map(item => item.button[1]))] }) check('label comes back at the same widths when the header grows; no overflow', sweep.every(item => back.find(other => other.width === item.width).label === item.label) && [...sweep, ...back].every(item => item.overflow <= 0)) - await header.evaluate(element => { element.style.width = '520px' }) + // 2026-11-13: More shows only below about 520px since 2026-10-26 (viewer.browser.mjs), so the header is set to 400px. + await header.evaluate(element => { element.style.width = '400px' }) await page.waitForTimeout(60) const more = header.getByRole('button', { name: '추가 도구' }) await more.click() @@ -223,7 +235,8 @@ let reference, viewerMenu, moreMenu, refTrigger // ---------- Responsive ---------- { // Tall enough that the 520 row and its popups are inside the viewport (the Story page does not scroll them in). - const page = await newPage(1920, 1500) + // 2026-11-13: 2500px wide so the 1920 row (shown only on a page of 1980px or more) is measured with the others. + const page = await newPage(2500, 1500) await open(page, 'sw-viewer--responsive') const headers = page.locator('[data-width] .hm-sw-viewer-header') const report = [] @@ -251,12 +264,21 @@ let reference, viewerMenu, moreMenu, refTrigger check('layer / settings / properties always in the header', report.every(item => JSON.stringify(item.toggles.map(toggle => toggle.name)) === '["레이어","설정","속성"]'), report.map(item => item.toggles.map(toggle => toggle.name))) // 2026-10-21 measured steps: full → icons only → More, by the measured room (no fixed width). With the file open button // after the logo, this Story's content fits fully at 1920 / 1440, as icons at 1280 / 950, and needs More at 860 / 760 / 520. + // 2026-11-13: since 2026-10-26 (user request; viewer.browser.mjs) the file name and the Model / Layout tabs give way first, + // so 860 / 760 / 520 keep every item as icons without More; More shows only below about 520px (the 520 row narrowed to 400). check('1920 / 1440: full items with labels (전체 보기 shown), no More; 1280 / 950: icons only, no More', report.slice(0, 2).every(item => item.fitShown && !item.more && item.toggles.every(toggle => toggle.label)) && report.slice(2, 4).every(item => !item.more && item.fitShown && item.toggles.every(toggle => !toggle.label && toggle.title)), report.map(item => [item.width, item.fitShown, item.more])) - check('860 / 760: icons only (tooltips kept) + More', report.slice(4, 6).every(item => item.more && item.toggles.every(toggle => !toggle.label && toggle.title)), report.slice(4, 6)) - check('520: icons only, then More for what still does not fit', report[6].more && report[6].toggles.every(toggle => !toggle.label && toggle.title), report[6]) + check('860 / 760: icons only (tooltips kept), no More', report.slice(4, 6).every(item => !item.more && item.toggles.every(toggle => !toggle.label && toggle.title)), report.slice(4, 6)) + check('520: icons only (tooltips kept), no More', !report[6].more && report[6].toggles.every(toggle => !toggle.label && toggle.title), report[6]) check('logo, file name and toolbar never overlap', report.every(item => item.logoRight <= item.docLeft + 2 + 0.5 && item.docRight <= item.objectLeft + 0.5), report.map(item => [item.width, item.logoRight, item.docLeft, item.docRight, item.objectLeft])) - // Every item is either in the header or in More, never both and never lost. + // Every item is either in the header or in More, never both and never lost: checked on the 520 row narrowed to 400px. + const narrowed = await page.evaluate(async () => { + const wrapper = document.querySelector('[data-width="520"]'); wrapper.style.maxWidth = '400px' + await new Promise(resolve => setTimeout(resolve, 400)) + const header = wrapper.querySelector('.hm-sw-viewer-header') + return { width: Math.round(header.getBoundingClientRect().width), overflow: header.scrollWidth - header.clientWidth, more: !!header.querySelector('.hm-sw-viewer-header__more') } + }) + check('520 row narrowed to 400: More shown, no overflow', narrowed.more && narrowed.overflow <= 0, narrowed) const more = headers.nth(6).getByRole('button', { name: '추가 도구' }) await more.click() const moreMenu = page.getByRole('menu', { name: '추가 도구' }) @@ -264,8 +286,9 @@ let reference, viewerMenu, moreMenu, refTrigger const inHeader = await headers.nth(6).evaluate(element => [...element.querySelectorAll('.hm-sw-viewer-header__object :is(.hm-sw-viewer-header__button:not(.hm-sw-viewer-header__more), [role=radio], .hm-sw-viewer-header__switch-label)')] .map(item => item.getAttribute('aria-label') ?? item.textContent.trim().replace(/[\d,]+$/, ''))) const all = ['Model', 'Layout', '전체 보기', '2.5D', '어두운 배경', '레이어', '설정', '속성'] - check('520: header + More hold every item exactly once (panel toggles never in More)', all.every(name => inHeader.includes(name) !== moreItems.includes(name)) && !moreItems.some(item => ['레이어', '설정', '속성'].includes(item)), { inHeader, moreItems }) + check('520 row at 400: header + More hold every item exactly once (panel toggles never in More)', all.every(name => inHeader.includes(name) !== moreItems.includes(name)) && !moreItems.some(item => ['레이어', '설정', '속성'].includes(item)), { inHeader, moreItems }) await page.keyboard.press('Escape') + await page.evaluate(async () => { document.querySelector('[data-width="520"]').style.maxWidth = ''; await new Promise(resolve => setTimeout(resolve, 400)) }) // Icon toggle at 520 still reports pressed / commands. const icon = headers.nth(3).getByRole('button', { name: '레이어', exact: true }) await icon.click() @@ -289,14 +312,15 @@ let reference, viewerMenu, moreMenu, refTrigger for (const [name, dialog] of [['레이어', /레이어 \(/], ['설정', '옵션'], ['속성', '속성']]) { const toggle = header.getByRole('button', { name, exact: true }) await toggle.hover() - const hover = await toggle.evaluate(element => getComputedStyle(element).backgroundColor) + // 2026-11-13: Viewer Header hover (caf4c32, 2026-10-21 user request): no face (transparent), text / icon #FFC600. + const hover = await toggle.evaluate(element => `${getComputedStyle(element).backgroundColor} / ${getComputedStyle(element).color}`) await toggle.click() const panel = page.getByRole('dialog', { name: dialog }) panels.push({ name, iconOnly: (await toggle.locator('.hm-sw-viewer-header__label').count()) === 0, title: await toggle.getAttribute('title'), hover, open: await panel.isVisible(), pressed: await toggle.getAttribute('aria-pressed'), color: await toggle.evaluate(element => getComputedStyle(element).color) }) await toggle.click() panels.at(-1).closed = (await panel.count()) === 0 && (await toggle.getAttribute('aria-pressed')) === 'false' } - check(`narrow header (${width}px): icon toggles with tooltip, hover, pressed #FFC600, panel open / close`, width < 900 && panels.every(item => item.iconOnly && item.title && item.hover === 'rgb(38, 102, 81)' && item.open && item.pressed === 'true' && item.color === 'rgb(255, 198, 0)' && item.closed), panels) + check(`narrow header (${width}px): icon toggles with tooltip, hover (no face, #FFC600), pressed #FFC600, panel open / close`, width < 900 && panels.every(item => item.iconOnly && item.title && item.hover === 'rgba(0, 0, 0, 0) / rgb(255, 198, 0)' && item.open && item.pressed === 'true' && item.color === 'rgb(255, 198, 0)' && item.closed), panels) await page.screenshot({ path: path.join(out, `viewer-narrow-${width}.png`), clip: { ...(await box(header)), height: 56 } }) await page.close() } @@ -353,6 +377,11 @@ let reference, viewerMenu, moreMenu, refTrigger // The header's own width is swept (10px steps) down and back up. Stage per width: 0 full, 1 icons only, 2+ More with // n items. Icons always come before More; narrower never has fewer steps; the way back gives the same state at // every width (no flicker, no stuck item); no overflow. A shorter file name moves every boundary to a narrower width. + // 2026-11-13 (current approved rules): the panel toggles turn into icons before 전체 보기 (2026-10-23, user-approved) and a + // button that became an icon stays one when narrower; below 560px the file name gives way to 56px (2026-10-26), so items + // may come back from More into the header — instead of a falling item count, every width that shows More opens it and every + // command must be in the header or in More exactly once. The steps use a header-width basis, not the file name width + // (caf4c32, 2026-10-21), so a shorter file name keeps the same boundaries. const page = await newPage(1920, 900) await open(page, 'sw-viewer--viewer-header') const header = page.locator('.hm-sw-viewer-header').first() @@ -365,8 +394,32 @@ let reference, viewerMenu, moreMenu, refTrigger const iconOnly = icons.length > 0 && icons.every(button => !button.querySelector('.hm-sw-viewer-header__label')) const anyIcon = icons.some(button => !button.querySelector('.hm-sw-viewer-header__label')) const more = !!element.querySelector('.hm-sw-viewer-header__more') - return { width, shown, iconOnly, anyIcon, more, stage: more ? 2 : iconOnly ? 1 : 0, overflow: Math.max(element.scrollWidth - element.clientWidth, object.scrollWidth - object.clientWidth - 1) } + const nameOf = button => button.getAttribute('aria-label') ?? button.textContent.trim() + const iconNames = icons.filter(button => !button.querySelector('.hm-sw-viewer-header__label')).map(nameOf) + const labelNames = icons.filter(button => button.getClientRects().length && button.querySelector('.hm-sw-viewer-header__label')).map(nameOf) + return { width, shown, iconOnly, anyIcon, iconNames, labelNames, more, stage: more ? 2 : iconOnly ? 1 : 0, overflow: Math.max(element.scrollWidth - element.clientWidth, object.scrollWidth - object.clientWidth - 1) } }, width) + // Commands shown in the header (visible only; a switch is named by its full label) and in the opened More menu. + const COMMANDS = ['Model', 'Layout', '전체 보기', '2.5D', '어두운 배경', '레이어', '설정', '속성'] + const PANEL_TOGGLES = ['레이어', '설정', '속성'] + const commandsAt = async width => { + const inHeader = await header.evaluate(async (element, width) => { + element.style.width = `${width}px` + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))) + return [...element.querySelectorAll('.hm-sw-viewer-header__object :is(button:not(.hm-sw-viewer-header__more), [role=radio], [role=switch])')] + .filter(item => item.getClientRects().length) + .map(item => item.getAttribute('role') === 'switch' ? element.querySelector(`label[for="${item.id}"] .hm-sw-viewer-header__switch-label-full`)?.textContent.trim() + : (item.getAttribute('aria-label') ?? item.textContent.trim()).replace(/[\d,]+$/, '')) + }, width) + await header.getByRole('button', { name: '추가 도구' }).click() + const menu = page.getByRole('menu', { name: '추가 도구' }) + await menu.waitFor({ timeout: 2000 }) + const inMore = (await menu.locator('[role^=menuitem]').allInnerTexts()).map(text => text.split(' · ')[0].trim()) + const menuBox = await menu.evaluate(element => { const r = element.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, vw: innerWidth, vh: innerHeight } }) + await page.keyboard.press('Escape') + const closed = (await page.getByRole('menu', { name: '추가 도구' }).count()) === 0 + return { width, inHeader: inHeader.filter(name => COMMANDS.includes(name)), inMore, menuBox, closed, pageOverflow: await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) } + } const sweep = async () => { const down = [], up = [] for (let width = 1440; width >= 400; width -= 10) down.push(await state(width)) @@ -376,17 +429,33 @@ let reference, viewerMenu, moreMenu, refTrigger const boundaries = list => ({ icons: list.find(item => item.stage >= 1)?.width, more: list.find(item => item.stage === 2)?.width }) const long = await sweep() check('sweep: no overflow at any width (400–1440)', [...long.down, ...long.up].every(item => item.overflow <= 0), [...long.down, ...long.up].filter(item => item.overflow > 0).map(item => item.width)) - check('sweep: icons switch together and always before More', [...long.down, ...long.up].every(item => item.anyIcon === item.iconOnly && (!item.more || item.iconOnly))) - check('sweep: narrower never shows more (monotonic: full → icons → More, fewer items)', long.down.every((item, i) => i === 0 || (item.stage >= long.down[i - 1].stage && item.shown <= long.down[i - 1].shown))) + check('sweep: icons always before More; panel toggles turn into icons before 전체 보기', [...long.down, ...long.up].every(item => (!item.more || item.iconOnly) + && (!item.iconNames.includes('전체 보기') || PANEL_TOGGLES.every(name => item.iconNames.includes(name)))), + [...long.down, ...long.up].filter(item => (item.more && !item.iconOnly) || (item.iconNames.includes('전체 보기') && !PANEL_TOGGLES.every(name => item.iconNames.includes(name)))).map(item => [item.width, item.iconNames])) + // A button that became an icon never shows its label again at a narrower width (it may move on into More). + check('sweep: a button that became an icon never gets its label back when narrower', long.down.every((item, i) => i === 0 || !long.down[i - 1].iconNames.some(name => item.labelNames.includes(name))), + long.down.filter((item, i) => i && long.down[i - 1].iconNames.some(name => item.labelNames.includes(name))).map(item => [item.width, item.labelNames])) + // Every width (10px steps) that shows More: More opened for real; each command exactly once in the header or in More, the panel + // toggles never in More, the menu inside the viewport and closed by Escape, no page overflow. Items moving back into the header + // when the file name gives way (below 560px) are allowed. + const moreWidths = long.down.filter(item => item.more).map(item => item.width) + const commands = [] + for (const width of moreWidths) commands.push(await commandsAt(width)) + const commandIssues = commands.filter(item => !COMMANDS.every(name => item.inHeader.filter(other => other === name).length + item.inMore.filter(other => other === name).length === 1) + || item.inMore.some(name => PANEL_TOGGLES.includes(name)) || item.inHeader.length + item.inMore.length !== COMMANDS.length + || !(item.menuBox.left >= 0 && item.menuBox.top >= 0 && item.menuBox.right <= item.menuBox.vw + 0.5 && item.menuBox.bottom <= item.menuBox.vh + 0.5) || !item.closed || item.pageOverflow > 0) + check(`sweep: every command exactly once in the header or More at each More width (${moreWidths.length} widths), menu inside the viewport, Escape closes`, moreWidths.length > 0 && commandIssues.length === 0, + commandIssues.length ? commandIssues.slice(0, 3) : { widths: [moreWidths.at(0), moreWidths.at(-1)], sample: commands.at(-1) }) check('sweep: growing back gives the same state at every width (reverse order, no stuck item)', long.down.every(item => { const back = long.up.find(other => other.width === item.width); return back.stage === item.stage && back.shown === item.shown })) check('sweep: 1440 full; each stage reached by measured room only', long.down[0].stage === 0 && long.down.at(-1).stage === 2, boundaries(long.down)) - // A short file name: the same content needs less room, so each step happens at a narrower width. + // A short file name: the steps use the header-width basis of the file name area (caf4c32, 2026-10-21: short and long names + // give the same result), so every boundary stays where it is with a long name. await header.evaluate(element => { element.style.width = '' }) await header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click() await header.getByRole('button', { name: 'simple.dxf' }).click() const short = await sweep() const [a, b] = [boundaries(long.down), boundaries(short.down)] - check('sweep: shorter file name moves the steps to narrower widths', b.icons < a.icons && b.more < a.more, { long: a, short: b }) + check('sweep: file name length does not move the steps (same boundaries for a short name)', a.icons !== undefined && a.more !== undefined && b.icons === a.icons && b.more === a.more, { long: a, short: b }) await page.close() } diff --git a/packages/ui/src/theme/lnb.css b/packages/ui/src/theme/lnb.css index 77c92a7..484a735 100644 --- a/packages/ui/src/theme/lnb.css +++ b/packages/ui/src/theme/lnb.css @@ -32,7 +32,8 @@ --hm-lnb-item-min-height: 48px; /* 1-depth row: 28px line + 2 x 10px = 48px. */ --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-font-size: 14px; --hm-lnb-item-line-height: 28px; diff --git a/src/center/center-facility.stories.tsx b/src/center/center-facility.stories.tsx index 32d5ccf..3c32447 100644 --- a/src/center/center-facility.stories.tsx +++ b/src/center/center-facility.stories.tsx @@ -1,16 +1,16 @@ -import type { ComponentType } from "react" +import type { ComponentType, ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" -import { TunnelFacility } from "@hm-design/center-graphic" import { - BridgeReferenceDemo, - IntersectionReferenceDemo, - InterchangeReferenceDemo, - JunctionReferenceDemo, - LinkedFacilityReferenceDemo, - RoadsideReferenceDemo, - TunnelReferenceDemo, -} from "@hm-design/center-graphic/reference" -import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" + CompactFacilityConnection, + FACILITY_CONFIGS, + LocalAssetFacility, + RoadsideFacilityTag, + TunnelFacility, + type FacilityConfig, + type RoadsideEntity, +} from "@hm-design/center-graphic" +import { LinkedFacilityReferenceDemo } from "@hm-design/center-graphic/reference" +import { StoryPage, StoryPageHeader } from "@/components/story-page" import { withRepresentation } from "./center-shared" // Owners: ui-viz docs/visualization/facility.md and facility-assets.md (128 approved SVG references, shipped unchanged). @@ -25,35 +25,225 @@ export default meta type Story = StoryObj // Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic. +// 2026-11-13: no visible "개요" section title (사업명 page rule); the specimens keep their indent via the common `sb-page-indent`. const page = (name: string, Demo: ComponentType): Story => ({ name, parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - +
{withRepresentation(() => )} - +
), }) +// 터널 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 ( +
+
+ {title} + {note ? {note} : null} +
+
{children}
+
+ ) +} + +function TunnelSpecimens() { + return ( +
+
+ {/* 2026-11-13: no "너비 동작" title row; each column carries the common specimen title (English + Korean note). */} +
+
+
+ {(["positionMap", "actualDetail"] as const).map((variant) => ( +
+
+ {variant === "positionMap" ? "Position Map" : "Actual / Detail"} + {variant === "positionMap" ? "위치도" : "실사화"} +
+
+ SEPARATED + +
+ {TUNNEL_WIDTH_NAMES.map(([size, name, length], index) => ( +
+ {variant === "positionMap" ? size : TUNNEL_ACTUAL_LABELS[index]} + +
+ ))} +
+ ))} +
+
+
+ + + + + + +
+
+ ) +} + +// 교량 · 교차로 · 분기점 · 나들목 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 ( +
+
+
+
+
+ {(["positionMap", "actualDetail"] as const).map((variant) => ( +
+
+ {variant === "positionMap" ? "Position Map" : "Actual / Detail"} + {variant === "positionMap" ? "위치도" : "실사화"} +
+
+ SEPARATED + +
+ {widthNames.map((name, index) => ( +
+ {variant === "positionMap" ? ["SHORT", "MEDIUM", "LONG"][index] : `ACTUAL ${["SHORT", "REF", "LONG"][index]}`} + +
+ ))} +
+ ))} +
+
+
+ + + + + + +
+
+ ) +} + +// 연계 노선 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 ( +
+
+
+
+
+
+
Position Map위치도
+ +
+
Actual / Detail실사화
+
+ SEPARATED + +
+ {ROADSIDE_TESTS.map((test) => ( +
+ {test.actual} + +
+ ))} +
+
+
+
+ + + + + + +
+
+ ) +} + // `name` is repeated as a literal because Storybook's static indexer cannot read it through page(); it drives the sidebar label. -export const Tunnel: Story = { ...page("터널", TunnelReferenceDemo), name: "터널" } -export const Bridge: Story = { ...page("교량", BridgeReferenceDemo), name: "교량" } -export const Intersection: Story = { ...page("교차로", IntersectionReferenceDemo), name: "교차로" } -export const Junction: Story = { ...page("분기점", JunctionReferenceDemo), name: "분기점" } -export const Interchange: Story = { ...page("나들목", InterchangeReferenceDemo), name: "나들목" } -export const Roadside: Story = { ...page("휴게소 · 졸음쉼터", RoadsideReferenceDemo), name: "휴게소 · 졸음쉼터" } +export const Tunnel: Story = { ...page("터널", TunnelSpecimens), name: "터널" } +export const Bridge: Story = { ...page("교량", () => ), name: "교량" } +export const Intersection: Story = { ...page("교차로", () => ), name: "교차로" } +export const Junction: Story = { ...page("분기점", () => ), name: "분기점" } +export const Interchange: Story = { ...page("나들목", () => ), name: "나들목" } +export const Roadside: Story = { ...page("휴게소 · 졸음쉼터", RoadsideSpecimens), name: "연계 노선" } // Linked facilities are a standalone section heading followed by item cards. // Keep the reference data/artwork and road-class grouping owned by the package. +// 2026-11-13: same `sb-page-indent` start as the other facility pages; the page class scopes the Linked-only CSS +// (hidden context notice / READY count / duplicate card name, card title and POSITION · ACTUAL label style). export const Linked: Story = { ...page("연계 시설물", LinkedFacilityReferenceDemo), name: "연계 시설물", render: () => ( - {withRepresentation(() => )} +
+ {withRepresentation(() => )} +
), } diff --git a/src/center/center-information.stories.tsx b/src/center/center-information.stories.tsx index e544dd9..5757a00 100644 --- a/src/center/center-information.stories.tsx +++ b/src/center/center-information.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite" import { CenterInformationDisplayReferenceDemo } from "@hm-design/center-graphic/reference" -import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" +import { StoryPage, StoryPageHeader } from "@/components/story-page" import { withRepresentation } from "./center-shared" // Owner: ui-viz docs/patterns/center-information-presentation.md and center-information-{box,table,emphasis}.md. @@ -19,13 +19,14 @@ export const Information: Story = { render: () => ( - + {/* 2026-11-13: no visible "개요" section title; indent kept via the common `sb-page-indent`. */} +
{withRepresentation(() => (
))} - +
), } diff --git a/src/center/center-shared.tsx b/src/center/center-shared.tsx index 44dacb5..468a79d 100644 --- a/src/center/center-shared.tsx +++ b/src/center/center-shared.tsx @@ -10,8 +10,10 @@ export const CENTER_BOUNDARY = export const SCALE_NOTE = "제작 대상이 7680 × 2160이면 CENTER 그래픽을 100%, 3840 × 2160이면 75%로 표시합니다. Storybook 화면 크기는 배율을 정하지 않으며, 다른 제작 해상도의 배율은 정해지지 않았습니다. 제작 대상은 주소에 centerResolution=3840x2160 을 붙여 바꿉니다." -export const CenterFrame = ({ children }: { children: ReactNode }) => ( -
+// `plain` drops the review card frames on 노선도 pages only. OrgChart previews reuse `.sb-center` and the card +// classes directly, so the plain layout is a CENTER-page modifier rather than a change to the shared frame. +export const CenterFrame = ({ children, plain = false }: { children: ReactNode; plain?: boolean }) => ( +
{children}
) @@ -23,7 +25,7 @@ export const centerTargetResolution = (): CenterTargetResolution => { } export const withRepresentation = (Story: () => ReactNode) => ( - + diff --git a/src/center/center-spatial.stories.tsx b/src/center/center-spatial.stories.tsx index 91cd86d..c505736 100644 --- a/src/center/center-spatial.stories.tsx +++ b/src/center/center-spatial.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite" import { RouteLine } from "@hm-design/center-graphic" import { CenterSpatialInformationReferenceDemo } from "@hm-design/center-graphic/reference" -import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" +import { StoryPage, StoryPageHeader } from "@/components/story-page" import { withRepresentation } from "./center-shared" // Owners: ui-viz docs/visualization/route.md, spatial-map.md, spatial-section.md, composition.md. @@ -21,9 +21,10 @@ export const Spatial: Story = { render: () => ( - + {/* 2026-11-13: no visible "개요" section title; indent kept via the common `sb-page-indent`. */} +
{withRepresentation(() => )} - +
), } diff --git a/src/center/center-stories.css b/src/center/center-stories.css index e5dcbf6..5890208 100644 --- a/src/center/center-stories.css +++ b/src/center/center-stories.css @@ -166,22 +166,8 @@ gap: var(--sb-center-section-gap); overflow-wrap: anywhere; } -.sb-center .linked-facility__card { - container: sb-linked-card / inline-size; -} -/* 56px label + 10px gap + measured 251px artwork + 48px common padding. - Stack the label when that row cannot fit; preserve artwork size and local scroll. */ -@container sb-linked-card (max-width: 365px) { - .sb-center .linked-facility__variant { - grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto; - justify-items: safe center; - gap: var(--sb-center-header-gap); - } - .sb-center .linked-facility__variant > small { - grid-row: auto; - } -} +/* 2026-11-13: the former narrow-card label stacking (`sb-linked-card` container) is replaced by the 연계 시설물 + card layout at the end of this file (POSITION | ACTUAL columns, `sb-linked-list` container). */ .sb-center .sb-center-status { min-width: 720px; } /* Spatial cards reuse the common frame above. Contrast belongs to the inner @@ -249,3 +235,371 @@ grid-template-columns: minmax(0, 1fr); } } + +/* 노선도 pages only (CenterFrame `plain`, 2026-11-12): no review card frame. + Each specimen is a plain heading + description followed by the artwork; wider + gaps replace the card edges. The shared frame above stays for OrgChart previews, + which reuse `.sb-center` and these card classes. Artwork, the spatial-section + #efefef canvas and the review-caption fixes above are unchanged. */ +.sb-center.sb-center--plain :is(.tunnel-reference__sample, .facility-reference__sample, + .center-title-reference__sample, .cid-demo__block, .linked-facility__card, .csi-panel) { + background: transparent; + border: 0; + border-radius: 0; +} +.sb-center.sb-center--plain :is(.tunnel-reference__sample, .facility-reference__sample, + .center-title-reference__sample, .cid-demo__block, .linked-facility__card) > header, +.sb-center.sb-center--plain .csi-panel > h4 { + min-height: 0; + gap: 4px; + margin: 0; + padding: 0; + background: transparent; + border-bottom: 0; +} +.sb-center.sb-center--plain .linked-facility__variant + .linked-facility__variant { + border-top: 0; +} +.sb-center.sb-center--plain .csi-panel { + padding: 0; +} + +/* 노선도 layout rhythm (2026-11-12): the common StorySection / StateBoard rules of + story-page.css and story-board.tsx (Button, Badge …), applied to the package demo DOM. + - 2026-11-13: no visible "개요" section; the Story wraps the specimens in the common `sb-page-indent` + (16px / 12px at 640px and below). 연계 시설물 keeps its own unindented page. + - Specimen title row (사업명 page rule, 2026-11-13): title 16/24/700 primary, description or size + 12/18 secondary 8px to the right on the same row (wraps only when the row runs out), + artwork 12px below, specimens 24px apart, columns 24px apart. Package text (Source / status) is kept. + - Artwork starts on the label's left edge; wide artwork keeps its local scroll. + - Linked road-class groups read as sections (16/24/700, 12px to content, 28px apart) + with their card grid indented, columns from a 320px minimum (StateBoard default). + Artwork size, colours and internal composition are untouched. */ +.sb-center.sb-center--plain, +.sb-center-linked-page .sb-center.sb-center--plain { + padding: 0; +} +/* Common specimen title row (2026-11-13, from the 사업명 page): package demo headers are + `header > strong + span`, the 사업명 Story header is `header > h3 + span`. Overrides the package's + space-between / narrow-screen column header on 노선도 pages only. */ +.sb-center.sb-center--plain :is(.tunnel-reference__sample, .facility-reference__sample, + .center-title-reference__sample, .cid-demo__block, .sb-center-tunnel__width-group) > header { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; + justify-content: flex-start; + column-gap: 8px; + row-gap: 0; +} +.sb-center.sb-center--plain :is(.tunnel-reference__sample, .facility-reference__sample, + .center-title-reference__sample, .cid-demo__block, .sb-center-tunnel__width-group) > header :is(strong, h3), +.sb-center.sb-center--plain .csi-panel > h4 { + margin: 0; + color: var(--hm-sem-fg-primary); + font-size: var(--sb-page-section-title-font-size, 16px); + line-height: var(--sb-page-section-title-line-height, 24px); + font-weight: var(--hm-font-weight-bold); +} +.sb-center.sb-center--plain :is(.tunnel-reference__sample, .facility-reference__sample, + .center-title-reference__sample, .cid-demo__block, .sb-center-tunnel__width-group) > header :is(span, small, p) { + color: var(--hm-sem-fg-secondary); + font-size: 12px; + line-height: 18px; +} +/* Exceptions kept as before: 연계 시설물 item cards (label 14/20/500) and the 노선·공간 long + descriptions below their title (legend / note paragraphs, 14/20 secondary). */ +.sb-center.sb-center--plain .linked-facility__card > header :is(strong, h3) { + color: var(--hm-sem-fg-secondary); + font-size: var(--hm-label-font-size); + line-height: var(--hm-label-line-height); + font-weight: var(--hm-font-weight-medium); +} +.sb-center.sb-center--plain .linked-facility__card > header :is(span, small, p), +.sb-center.sb-center--plain .csi-panel > :is(.csi-legend, .csi-note) { + color: var(--hm-sem-fg-secondary); + font-size: var(--hm-label-font-size); + line-height: var(--hm-label-line-height); +} +/* Representation note ("표시 배율 · 제작 대상"): its own row on the content's right edge + (flex end, wraps instead of overflowing). The 사업명 page shows it in its first title row instead. */ +.sb-center.sb-center--plain .center-representation__note { + display: flex; + justify-content: flex-end; + text-align: right; + overflow-wrap: anywhere; +} +/* csi-panel is a 12px grid: pull the description up to 4px under its label. */ +.sb-center.sb-center--plain .csi-panel > h4 + .csi-legend { + margin-top: -8px; +} +.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage, + .center-title-reference__stage, .cid-demo__stage, .linked-facility__variant) { + padding: 12px 0 0; +} +/* The demo stages' minimum heights centred the artwork in an empty frame (card era); + without the card they only push the artwork away from its label, so the stage + now follows the artwork's own height and starts at its top-left. */ +.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage) { + min-height: 0; + align-items: start; + justify-items: start; +} +.sb-center.sb-center--plain :is(.center-title-reference__stage, .cid-demo__stage) { + min-height: 0; + align-items: flex-start; + justify-content: flex-start; +} +.sb-center.sb-center--plain :is(.tunnel-reference__row, .facility-reference__row) { + justify-content: flex-start; +} +.sb-center.sb-center--plain .linked-facility__variant { + min-height: 0; + justify-items: start; +} +.sb-center.sb-center--plain :is(.tunnel-reference, .facility-reference, .center-title-reference, + .cid-demo, .csi-demo) { + gap: 24px; +} +.sb-center.sb-center--plain :is(.tunnel-reference__variant-grid, .facility-reference__variant-grid) { + gap: 24px; +} +/* 2026-11-13 (시설물 layout): road-class sections 24px apart, the 노선도 section gap. */ +.sb-center.sb-center--plain .linked-facility { + gap: 24px; +} +/* 2026-11-13: header → card grid is the header's own 12px padding, like the common section title (story-page.css). */ +.sb-center.sb-center--plain .linked-facility__group { + gap: 0; +} +/* 2026-11-13 (시설물 layout): common title row — description 12px secondary 8px right of the title (wraps when needed). */ +.sb-center.sb-center--plain .linked-facility__group > header { + justify-content: flex-start; + align-items: baseline; + column-gap: 8px; + row-gap: 0; + padding-bottom: 12px; + /* 2026-11-13: no divider, matching the common section title (story-page.css). */ +} +.sb-center.sb-center--plain .linked-facility__group > header h3 { + color: var(--hm-sem-fg-primary); + font-size: var(--sb-page-section-title-font-size, 16px); + line-height: var(--sb-page-section-title-line-height, 24px); + font-weight: var(--hm-font-weight-bold); +} +.sb-center.sb-center--plain .linked-facility__group > header span { + color: var(--hm-sem-fg-secondary); + font-size: 12px; + line-height: 18px; +} +.sb-center.sb-center--plain .linked-facility__grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); + gap: 24px; + padding-left: var(--sb-page-indent, 16px); +} + +/* 연계 시설물 page only (2026-11-13, StoryPage `sb-center-linked-page`). The package demo is unchanged; hidden here are only + repeated texts: the top context notice (two phrases + grey bar, a layout note rather than a specimen), the road-class + "N READY 시설물 집합" count (the cards themselves show it) and the card's first name (터널 / 교량 / 교차로 repeat the Korean + name, JC / IC are printed on the artwork). The Korean name is the card title, shown once. The page uses `sb-page-indent`, so + the card grid drops its own indent: titles and cards start on the same line as the other facility pages. + Card layout (2026-11-13, replaces the 320px auto-fit grid): each card is title + two columns, POSITION | ACTUAL (the + package's two `__variant` blocks, label 11px / 700 / .08em), each column label → full artwork 8px → split artwork 16px; the + columns share their rows (subgrid), so full and split artwork line up. Widths measured on all 17 cards: widest POSITION + 240px, widest ACTUAL 252px → a 2-column card needs 240 + 24 + 252 = 516px, two cards per row 516 × 2 + 24 = 1056px. The + switches follow the list width (`sb-linked-list` container): ≥ 1056px 2 cards per row, below 1 card; ≥ 516px POSITION | + ACTUAL, below stacked (24px apart). Odd counts leave the last card on the left, no filler. No card frame (plain 노선도). + Artwork, split pieces, the 12px title gap, the 24px section / card gaps and the 8px local scrollbar are unchanged. */ +.sb-center-linked-page :is(.linked-facility__context, .linked-facility__group > header > span, .linked-facility__card > header > strong) { + display: none; +} +.sb-center-linked-page .sb-center.sb-center--plain .linked-facility { + container: sb-linked-list / inline-size; +} +.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__grid { + grid-template-columns: minmax(0, 1fr); + gap: 24px; + padding-left: 0; +} +.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__card { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: start; + gap: 12px; +} +.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__card > header > span { + color: var(--hm-sem-fg-primary); + font-size: var(--hm-label-font-size); + line-height: var(--hm-label-line-height); + font-weight: var(--hm-font-weight-bold); + text-align: left; +} +/* One column: label, full artwork, split artwork. Overrides the package's 56px label column / 1fr 1fr rows. The stage keeps + its overflow-x: auto, so artwork wider than the column scrolls locally with the 8px bar. */ +.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__variant { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto; + row-gap: 16px; + column-gap: 0; + align-items: start; + justify-items: start; + padding: 0; +} +.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__variant + .linked-facility__variant { + margin-top: 12px; /* card gap 12px + 12px = 24px between the stacked POSITION and ACTUAL blocks */ +} +.sb-center-linked-page .sb-center.sb-center--plain .linked-facility__variant > small { + grid-row: auto; + margin-bottom: -8px; /* 16px row gap - 8px = label → full artwork 8px */ + font-size: 11px; +} +@container sb-linked-list (min-width: 516px) { + .sb-center-linked-page .sb-center.sb-center--plain .linked-facility__card { + grid-template-columns: repeat(2, minmax(max-content, 1fr)); + grid-template-rows: repeat(4, auto); + column-gap: 24px; + } + .sb-center-linked-page .sb-center.sb-center--plain .linked-facility__card > header { + grid-column: 1 / -1; + } + .sb-center-linked-page .sb-center.sb-center--plain .linked-facility__variant { + grid-row: 2 / span 3; + grid-template-rows: subgrid; + } + .sb-center-linked-page .sb-center.sb-center--plain .linked-facility__variant + .linked-facility__variant { + margin-top: 0; + } +} +@container sb-linked-list (min-width: 1056px) { + .sb-center-linked-page .sb-center.sb-center--plain .linked-facility__grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* 사업명 page only (2026-11-13, StoryPage className): the representation note ("표시 배율 · 제작 대상") + is shown in the first specimen's title row (.sb-center-title-note, center-title.stories.tsx), so the + package's separate note block above the specimens is hidden here. The note keeps the size text style + (12px secondary, common title row rule) and sits on the row's right edge (margin-left: auto in the + wrapping flex row, i.e. space-between for "name + size" | note); when the row runs out of room only + the note wraps to the next line, still right-aligned. No absolute positioning. */ +.sb-center-title-page .sb-center.sb-center--plain .center-representation__note { + display: none; +} +.sb-center.sb-center--plain .center-title-reference__sample > header.sb-center-title-row .sb-center-title-note { + margin-left: auto; + text-align: right; +} + +/* 2026-11-13: graphic local scroll cue (노선도 pages only). CENTER artwork keeps its real size, so wide + specimens scroll horizontally inside their stage; with overlay scrollbars that read as clipping. + These stages get a visible 8px horizontal scrollbar, which only appears when the stage actually + overflows (overflow-x: auto is unchanged). Thumb colours reuse the HM Scroll Area tokens. + Chromium/WebKit use ::-webkit-scrollbar (forces a classic bar); others get the standard thin bar. + Artwork size, the 12px title gap and the 24px specimen gap are untouched. */ +.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage, + .center-title-reference__stage, .cid-demo__stage, .linked-facility__variant, .csi-panel > div)::-webkit-scrollbar { + height: 8px; + background: transparent; +} +.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage, + .center-title-reference__stage, .cid-demo__stage, .linked-facility__variant, .csi-panel > div)::-webkit-scrollbar-thumb { + border-radius: 9999px; + background: var(--hm-scroll-area-thumb-background, var(--hm-sem-stroke-default)); +} +.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage, + .center-title-reference__stage, .cid-demo__stage, .linked-facility__variant, .csi-panel > div)::-webkit-scrollbar-thumb:hover { + background: var(--hm-scroll-area-thumb-hover-background, var(--hm-sem-fg-secondary)); +} +@supports not selector(::-webkit-scrollbar) { + .sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage, + .center-title-reference__stage, .cid-demo__stage, .linked-facility__variant, .csi-panel > div) { + scrollbar-width: thin; + scrollbar-color: var(--hm-scroll-area-thumb-background, var(--hm-sem-stroke-default)) transparent; + } +} + +/* 터널 page layout (2026-11-13, center-facility.stories.tsx `TunnelSpecimens`). The breakpoint follows the + preview content width (container query). One width-behaviour section: Position Map | Actual / Detail + columns from 1180px (the widest row: 64px label + 16px + 497px artwork, twice, plus the 24px gap), + 1 column below. Each column is title + SEPARATED + SHORT / MEDIUM / LONG; the two groups share the + parent rows (subgrid), so each step lines up with its Actual counterpart. + Artwork size, the package row label style, the 12px title gap, the 24px specimen gap and the 8px + local scrollbar are unchanged; narrow stages keep their horizontal scroll. */ +.sb-center-tunnel { + container-type: inline-size; + min-width: 0; +} +.sb-center-tunnel__width { + display: grid; + grid-template-columns: minmax(max-content, 1fr); + grid-auto-rows: auto; + column-gap: 24px; + row-gap: 16px; + width: 100%; +} +.sb-center.sb-center--plain .sb-center-tunnel__width > .sb-center-tunnel__width-group { + display: grid; + grid-row: span 5; + grid-template-rows: subgrid; + gap: inherit; + width: auto; +} +.sb-center-tunnel__width-group + .sb-center-tunnel__width-group { + margin-top: 8px; /* 16px row gap + 8px = the 24px specimen gap between the stacked groups */ +} +/* 2026-11-13: no "너비 동작" title row. Each column starts with the common specimen title row + (`header > strong + span`, rule above: 16/24/700 + 12px note), so both titles share row 1 and start on + the same line. The stage loses its 12px title gap (no title above it); title → first row stays 12px. */ +.sb-center.sb-center--plain :is(.tunnel-reference__stage, .facility-reference__stage).sb-center-tunnel__width-stage { + padding-top: 0; +} +.sb-center.sb-center--plain .sb-center-tunnel__width-group > header { + margin-bottom: -4px; /* 16px row gap - 4px = the common 12px title → artwork gap */ +} +/* 연계 노선: Position Map has no Separated row. Its empty first row is shown only at 2 columns (1180px block below: row + alignment with the Actual / Detail SEPARATED row); at 1 column it is not rendered and the group spans 4 rows. + Kept before the breakpoint blocks so their `span 5` wins at 2 columns (same specificity, source order). */ +.sb-center-roadside__position > .sb-center-roadside__blank { + display: none; /* outranks the package row rule `.facility-reference__width-tests > div { display: grid }` */ +} +.sb-center.sb-center--plain .sb-center-tunnel__width > .sb-center-tunnel__width-group.sb-center-roadside__position { + grid-row: span 4; +} +/* 교량 · 교차로 · 분기점 · 나들목 · 연계 노선 (2026-11-13) reuse this layout. A page whose widest row does not fit at 1180px + sets `data-sb-compare-at` (교량 1210px) and switches in its own block below; the rule set is the same. */ +@container (min-width: 1180px) { + .sb-center-tunnel:not([data-sb-compare-at]) .sb-center-tunnel__width { + grid-template-columns: repeat(2, minmax(max-content, 1fr)); + } + .sb-center.sb-center--plain .sb-center-tunnel:not([data-sb-compare-at]) .sb-center-tunnel__width > .sb-center-tunnel__width-group { + grid-row: 1 / span 5; + } + .sb-center-tunnel:not([data-sb-compare-at]) .sb-center-tunnel__width-group + .sb-center-tunnel__width-group { + margin-top: 0; + } + .sb-center-tunnel:not([data-sb-compare-at]) .sb-center-roadside__blank { + display: block; + } +} +@container (min-width: 1210px) { + .sb-center-tunnel[data-sb-compare-at="1210"] .sb-center-tunnel__width { + grid-template-columns: repeat(2, minmax(max-content, 1fr)); + } + .sb-center.sb-center--plain .sb-center-tunnel[data-sb-compare-at="1210"] .sb-center-tunnel__width > .sb-center-tunnel__width-group { + grid-row: 1 / span 5; + } + .sb-center-tunnel[data-sb-compare-at="1210"] .sb-center-tunnel__width-group + .sb-center-tunnel__width-group { + margin-top: 0; + } +} + +/* 노선·공간 page only (2026-11-13, StoryPage className `sb-center-spatial-page`): example-first layout. + The package demo's descriptions (.csi-legend), KNOWN_DEVIATION notes (.csi-note) and the representation + note ("표시 배율 · 제작 대상") are hidden here; the package source is unchanged. With them gone the + existing rhythm applies as-is: title → label/artwork 12px (--sb-center-header-gap), specimens 24px apart. + The section 2 sample controls (.csi-controls, user request 2026-11-13) are hidden too, so the examples + show the demo's default 기준 (Source 표본) content. Section titles, specimen labels and artwork stay visible. */ +.sb-center-spatial-page .sb-center.sb-center--plain :is(.csi-panel > .csi-legend, .csi-panel > .csi-note, + .csi-panel > .csi-controls, .center-representation__note) { + display: none; +} diff --git a/src/center/center-title.stories.tsx b/src/center/center-title.stories.tsx index 7c73c87..4642f01 100644 --- a/src/center/center-title.stories.tsx +++ b/src/center/center-title.stories.tsx @@ -1,8 +1,54 @@ import type { Meta, StoryObj } from "@storybook/react-vite" import { CenterTitle, CENTER_TITLE_COMPOSITIONS, type CenterTitleComposition } from "@hm-design/center-graphic" -import { CenterTitleReferenceDemo } from "@hm-design/center-graphic/reference" -import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" -import { withRepresentation } from "./center-shared" +import type { ComponentProps } from "react" +import { StoryPage, StoryPageHeader } from "@/components/story-page" +import { centerTargetResolution, withRepresentation } from "./center-shared" + +// 사업명 page specimens (2026-11-13): composed here from the public API instead of the package reference demo, so the +// page shows "composition name + size" on one row without the Type / Source viewport labels. The package demo +// (`@hm-design/center-graphic/reference`) is unchanged; the stage class keeps the existing artwork backdrop. +const TITLE_SPECIMENS: { key: string; title: string; meta: string; props: ComponentProps }[] = [ + ...(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 ( +
+ {TITLE_SPECIMENS.map(({ key, title, meta, props }, index) => ( +
+
+

{title}

+ {meta} + {index === 0 ? {representationNote()} : null} +
+
+ +
+
+ ))} +
+ ) +} // Owner: ui-viz docs/components/center-title.md. Title Resolution B numeric output is TBD; the per-Type palette is SCOPED_DESIGN_APPROVAL. const meta = { @@ -20,11 +66,13 @@ export const Compositions: Story = { name: "사업명", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - + // 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`). + - - {withRepresentation(() => )} - +
+ {withRepresentation(() => )} +
), } diff --git a/src/chart/area-chart.stories.tsx b/src/chart/area-chart.stories.tsx index eae9803..0ed8195 100644 --- a/src/chart/area-chart.stories.tsx +++ b/src/chart/area-chart.stories.tsx @@ -22,22 +22,22 @@ export const Area: Story = { {/* Same grid and order as Line Chart, then a Stacked | Expanded row. */} - + - + - + - + - + - + diff --git a/src/chart/bar-chart.stories.tsx b/src/chart/bar-chart.stories.tsx index 4178514..247e6ae 100644 --- a/src/chart/bar-chart.stories.tsx +++ b/src/chart/bar-chart.stories.tsx @@ -26,7 +26,7 @@ export const Bar: Story = { - + diff --git a/src/chart/chart-story.tsx b/src/chart/chart-story.tsx index 92b90f8..301d518 100644 --- a/src/chart/chart-story.tsx +++ b/src/chart/chart-story.tsx @@ -4,7 +4,7 @@ import { StorySection } from "@/components/story-page" // Storybook presentation only (not part of @hm-design/chart): the open Chart page layout (2026-11-12, user request). // Charts sit directly on the Storybook content background, without the example card (background, border, padding). Each chart -// is one StorySection: H2 variant name → description (what the example shows) → thin section divider → optional toolbar → chart. +// is one StorySection: H2 variant name → optional description (what the example shows) → 12px → optional toolbar → chart. // The charts themselves are the public @hm-design/chart components on the @hm-design/chart/examples sample data, with the same // props as the matching *Example card, so data, legend, tooltip and interaction are unchanged. No common layout change; the page // only sets the chart's own surface token (chartPageClass). diff --git a/src/chart/donut-chart.stories.tsx b/src/chart/donut-chart.stories.tsx index 7c45c63..877f458 100644 --- a/src/chart/donut-chart.stories.tsx +++ b/src/chart/donut-chart.stories.tsx @@ -56,17 +56,17 @@ export const Donut: Story = { {/* Two per row from 1024px, three from 1200px, one below; source order is the mobile order. */} - + - + - + - + diff --git a/src/chart/line-chart.stories.tsx b/src/chart/line-chart.stories.tsx index 50a96fa..52862ba 100644 --- a/src/chart/line-chart.stories.tsx +++ b/src/chart/line-chart.stories.tsx @@ -23,22 +23,22 @@ export const Line: Story = { {/* Source order is the mobile order. 1024px: Curve | Curve + labels per row. 1200px: three columns, plain styles in the first row and their Dot + Label versions below. */} - + - + - + - + - + - + diff --git a/src/chart/radar-chart.stories.tsx b/src/chart/radar-chart.stories.tsx index 0f5b425..8c83abf 100644 --- a/src/chart/radar-chart.stories.tsx +++ b/src/chart/radar-chart.stories.tsx @@ -21,16 +21,16 @@ export const Radar: Story = { {/* Two per row from 1024px, all four in one row from 1600px, one below. */} - + - + - + - + diff --git a/src/chart/radial-chart.stories.tsx b/src/chart/radial-chart.stories.tsx index fe91301..6677edc 100644 --- a/src/chart/radial-chart.stories.tsx +++ b/src/chart/radial-chart.stories.tsx @@ -21,22 +21,22 @@ export const Radial: Story = { {/* Two per row from 1024px, three from 1200px, one below; source order is the mobile order. */} - + - + - + - + - + - + diff --git a/src/components/accordion-story.css b/src/components/accordion-story.css new file mode 100644 index 0000000..3ae7b96 --- /dev/null +++ b/src/components/accordion-story.css @@ -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)); +} diff --git a/src/components/accordion.stories.tsx b/src/components/accordion.stories.tsx index 7e9763e..1428f39 100644 --- a/src/components/accordion.stories.tsx +++ b/src/components/accordion.stories.tsx @@ -1,8 +1,16 @@ import type { ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" 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 { + 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 // 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: }, - { id: "collapsible-single", label: "Collapsible Single", node: }, - { id: "multiple", label: "Multiple", node: }, - { id: "disabled", label: "Disabled Item", node: }, - { id: "long", label: "Long Content", node: }, -] - // 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 export default meta type Story = StoryObj -// 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 -// 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 ( -
- {states.map((state) => ( - - ))} -
- ) -} - -const board = (cols: 2 | 3, ...ids: string[]) => ( - 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. +// The combined disclosure page uses the same specimens as the separate verification Stories. export const All: Story = { name: "Accordion", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - + - {board(3, "single", "collapsible-single", "multiple")} - {board(3, "disabled", "long")} + + }, + { id: "collapsible-single", label: "Collapsible Single", children: }, + { id: "multiple", label: "Multiple", children: }, + ]} /> + + + }, + ]} /> + + + }, + { id: "collapsible-open", label: "Open", children: }, + { id: "collapsible-disabled", label: "Disabled", children: }, + ]} /> + + + }, + { id: "collapsible-long", label: "Collapsible · Long Content", children: }, + { id: "collapsible-controlled", label: "Button trigger", children: }, + ]} /> + ), } diff --git a/src/components/alert-dialog-examples.tsx b/src/components/alert-dialog-examples.tsx new file mode 100644 index 0000000..eebb87c --- /dev/null +++ b/src/components/alert-dialog-examples.tsx @@ -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 ( + + + + + + + 로그아웃하시겠습니까? + 저장하지 않은 변경 사항은 사라집니다. + + + 취소 + 로그아웃 + + + + ) +} + +export function AlertDialogDestructive({ defaultOpen }: { defaultOpen?: boolean }) { + return ( + + + + + + + 프로젝트를 삭제하시겠습니까? + 삭제하면 되돌릴 수 없습니다. 프로젝트의 모든 데이터가 함께 삭제됩니다. + + + 취소 + + 삭제 + + + + + ) +} diff --git a/src/components/alert-dialog.stories.tsx b/src/components/alert-dialog.stories.tsx index 70a70fa..9828434 100644 --- a/src/components/alert-dialog.stories.tsx +++ b/src/components/alert-dialog.stories.tsx @@ -1,76 +1,19 @@ import type { Meta, StoryObj } from "@storybook/react-vite" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, - Button, -} from "@hm-design/ui" +import { AlertDialog } from "@hm-design/ui" import { StateBoard, type BoardState } from "@/components/story-board" 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 -// dismiss. The representative Story opens the real dialogs; the same cases start open in the verification Stories. -// Action / Cancel are the HM Button: ordinary confirmation uses a Solid pair; deletion uses an Outline pair. - -function Confirmation({ defaultOpen }: { defaultOpen?: boolean }) { - return ( - - - - - - - 로그아웃하시겠습니까? - 저장하지 않은 변경 사항은 사라집니다. - - - 취소 - 로그아웃 - - - - ) -} - -function Destructive({ defaultOpen }: { defaultOpen?: boolean }) { - return ( - - - - - - - 프로젝트를 삭제하시겠습니까? - 삭제하면 되돌릴 수 없습니다. 프로젝트의 모든 데이터가 함께 삭제됩니다. - - - 취소 - - 삭제 - - - - - ) -} +// dismiss. The specimens live in ./alert-dialog-examples and are also shown on the Dialog 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. const meta = { title: "Components/Overlay/Alert Dialog", component: AlertDialog, parameters: { layout: "centered" } } satisfies Meta export default meta type Story = StoryObj -// 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 -// (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 // 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 }) { @@ -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 = { + tags: ["verification"], name: "Alert Dialog", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( diff --git a/src/components/aspect-ratio.stories.tsx b/src/components/aspect-ratio.stories.tsx index 3fcbf40..7aa375d 100644 --- a/src/components/aspect-ratio.stories.tsx +++ b/src/components/aspect-ratio.stories.tsx @@ -58,7 +58,7 @@ export default meta type Story = StoryObj // 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 Card example spans two columns (about its previous width); the ratio boxes fill their column by design. const specimenColumns = { diff --git a/src/components/avatar.stories.tsx b/src/components/avatar.stories.tsx index 1dd74b7..b530df8 100644 --- a/src/components/avatar.stories.tsx +++ b/src/components/avatar.stories.tsx @@ -77,7 +77,7 @@ export default meta type Story = StoryObj // 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. const specimenColumns = { 2: "min-[1000px]:grid-cols-2", diff --git a/src/components/badge.stories.tsx b/src/components/badge.stories.tsx index 2fab003..6aaac24 100644 --- a/src/components/badge.stories.tsx +++ b/src/components/badge.stories.tsx @@ -24,7 +24,7 @@ export default meta type Story = StoryObj // 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. const specimenColumns = { 2: "min-[1000px]:grid-cols-2", diff --git a/src/components/breadcrumb.stories.tsx b/src/components/breadcrumb.stories.tsx index f372a3b..9b8a6b7 100644 --- a/src/components/breadcrumb.stories.tsx +++ b/src/components/breadcrumb.stories.tsx @@ -99,7 +99,7 @@ function CustomLink() { - 홈 (custom link) + 홈 / @@ -115,15 +115,15 @@ const sections = [ { id: "basic", label: "Basic", node: }, { id: "ellipsis", label: "Ellipsis", node: }, { id: "long", label: "Long path", node: }, - { id: "custom", label: "Custom link · separator", node: }, + { id: "custom", label: "Custom", node: }, ] const meta = { title: "Components/Navigation/Breadcrumb", component: Breadcrumb, parameters: { layout: "padded" } } satisfies Meta export default meta type Story = StoryObj -// 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 +// Representative-page layout (Storybook presentation only): Variants use 3 equal columns on PC (iframe width 1000px and up) +// 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. const specimen = (id: string, children?: ReactNode): BoardState => { 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. -// 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 -// PC; BreadcrumbList is `flex-wrap`, so it wraps naturally (never clipped or forced nowrap) when the space is narrower. +// Overview is merged into Variants (Basic is its first item). Usage (Long path) takes the full section width (2026-11-13, the +// former 900px cap is removed); BreadcrumbList is `flex-wrap`, so it wraps naturally (never clipped or forced nowrap) when narrower. export const All: Story = { name: "Breadcrumb", parameters: { layout: "fullscreen", controls: { disable: true } }, @@ -140,14 +140,14 @@ export const All: Story = { -
+
{["basic", "ellipsis", "custom"].map((id) => ( ))}
- {sections.find((s) => s.id === "long")!.node}
)]} /> + {sections.find((s) => s.id === "long")!.node}
)]} /> ), diff --git a/src/components/button.stories.tsx b/src/components/button.stories.tsx index 2ec50b1..84e6990 100644 --- a/src/components/button.stories.tsx +++ b/src/components/button.stories.tsx @@ -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 type { Meta, StoryObj } from "@storybook/react-vite" import { Button, type ButtonAppearance } from "@hm-design/ui" 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): // 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. 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 -// button line up. At 640px and below the label moves above its buttons. Buttons keep the page-wide 8px gap, across and between -// rows (same as the States matrices), and wrap only when too narrow. -function GroupRows({ groups }: { groups: { id: string; label: string; children: ReactNode }[] }) { +// 2026-11-13 (user request): one column per treatment, Sizes and States inside it. Each block is its H3 label (StateBoard H3 +// style), 8px, then the specimens; Sizes and States are 24px apart. The blocks stay on the section edge (no data-sb-board +// indent) so the 420px Standard Solid matrix fits an equal third at the 1400px breakpoint without an inner scroll. +function GroupBlock({ id, label, children }: { id: string; label: string; children: ReactNode }) { return ( -
- {groups.map(({ id, label, children }) => ( -
-

{label}

-
{children}
-
- ))} -
- ) -} - -// 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 ( -
- {groups.map(({ id, label, children }) => ( -
-

{label}

- {children} -
- ))} -
+
+

{label}

+ {children} +
) } // 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. -// Columns follow their buttons (auto table layout). 4px cell padding gives the same 8px gap as Sizes / Variants between -// buttons, rows, the state label and the color header; the outer edge keeps none (pl-0 / last:pr-0) so the three matrices -// fit one row at 1440px. Buttons sit centered under their color and are never resized. +// 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 between buttons, rows, +// the state label and the color header; the outer edge keeps none (pl-0 / last:pr-0). Buttons sit centered under their color +// 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>(() => 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 {children} +} + function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; states: readonly (readonly [string, Record])[] }) { const colors = rolesFor(appearance) + const { stacked, report } = useContext(MatrixFit) + const frame = useRef(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 ( +
+ {states.map(([state, props], index) => ( +
+ + {state} + +
+ {colors.map(([variant, color]) => ( + + ))} +
+
+ ))} +
+ ) + } return ( -
+
@@ -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 +} + +// 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. +// 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. export const All: Story = { name: "Button", @@ -320,45 +373,37 @@ export const All: Story = { render: () => ( - {/* Sizes | Variants on one row from a 1080px preview (1280px screen with the 180px LNB), sharing the whole width in two - equal halves (never narrower than their rows). Narrower previews stack the two sections. */} - - - ({ - id: `sizes-${appearance}`, - label, - children: sizes.map(([size, sizeLabel]) => ( - - )), - }))} - /> - - - ({ - id: `roles-${appearance}`, - label, - children: rolesFor(appearance).map(([variant, color]) => ( - - )), - }))} - /> - - - - ({ - id: `${appearance}-states`, - label, - children: , - }))} - /> - + {/* Equal-width columns from a 1400px preview (the 420px Standard Solid matrix fits a third: 3 × 420 + 2 × 24 plus the page + margins and the scrollbar); below that Brand → Standard Solid → Standard Outline in one column, 28px apart (the + common section gap of StorySectionGrid). */} + + + {appearances.map(([appearance, label]) => ( + +
+ +
+ {sizes.map(([size, sizeLabel]) => ( + + ))} +
+
+ + + +
+
+ ))} +
+ {/* 2026-11-13 (user request): Toggle Button joins the Button page ("버튼") after the treatment columns, from + ./toggle-examples unchanged; the Toggle Stories keep their IDs and direct URLs but are hidden from the sidebar. + `flat`: one "Toggle Button" H2 section, 36px below the columns above, with 3 equal group columns (Variants / States, + Groups, Sizes) from 1400px like the treatments, each row in the States matrix form (label left, 8px rhythm). + Inside MatrixFitProvider so its rows switch to the stacked form together with the matrices. */} + +
), } diff --git a/src/components/calendar-examples.tsx b/src/components/calendar-examples.tsx new file mode 100644 index 0000000..c70481e --- /dev/null +++ b/src/components/calendar-examples.tsx @@ -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) =>
{node}
+ +// `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(new Date(2026, 9, 15)) + return ( +
+ {box()} + {showValue ? {`선택: ${date ? date.toLocaleDateString("ko-KR") : "없음"}`} : null} +
+ ) +} + +export function DisabledDates() { + const [date, setDate] = useState() + return box( + + ) +} + +export function Range() { + const [range, setRange] = useState({ from: new Date(2026, 9, 8), to: new Date(2026, 9, 14) }) + return box() +} + +function TwoMonths() { + return box() +} + +// 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 }) => ( +
{children}
+) +const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?: boolean; children: ReactNode }) => ( +
+ +
+) + +// 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 ( + <> + + + + + + + + + + + + + + ) +} diff --git a/src/components/calendar.stories.tsx b/src/components/calendar.stories.tsx index 205df44..95f20fb 100644 --- a/src/components/calendar.stories.tsx +++ b/src/components/calendar.stories.tsx @@ -1,88 +1,26 @@ -import { useState, type ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" -import type { DateRange } from "react-day-picker" -import { Calendar } from "@hm-design/ui" -import { StateBoard } from "@/components/story-board" -import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" +import { StoryPage, StoryPageHeader } from "@/components/story-page" +import { CalendarSections, DisabledDates, Range, Single } from "./calendar-examples" -// The representative "Calendar" Story shows each mode with real, clickable calendars; the same specimens also exist as Stories -// tagged `verification` (hidden from the sidebar). `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) =>
{node}
- -// `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(new Date(2026, 9, 15)) - return ( -
- {box()} - {showValue ? {`선택: ${date ? date.toLocaleDateString("ko-KR") : "없음"}`} : null} -
- ) -} - -function DisabledDates() { - const [date, setDate] = useState() - return box( - - ) -} - -function Range() { - const [range, setRange] = useState({ from: new Date(2026, 9, 8), to: new Date(2026, 9, 14) }) - return box() -} - -function TwoMonths() { - return box() -} - -// 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 }) => ( -
{children}
-) -const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?: boolean; children: ReactNode }) => ( -
- -
-) +// The Calendar specimens live in ./calendar-examples and are also shown on the Date Picker 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`). // 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 export default meta type Story = StoryObj -// 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 = { + tags: ["verification"], name: "Calendar", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - - - - - - - - - - - - + ), } diff --git a/src/components/carousel.stories.tsx b/src/components/carousel.stories.tsx index dcb8576..e2d8747 100644 --- a/src/components/carousel.stories.tsx +++ b/src/components/carousel.stories.tsx @@ -143,7 +143,7 @@ export default meta type Story = StoryObj // 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 Media example spans two columns; carousels fill their column by design. 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. export const All: Story = { name: "Carousel", diff --git a/src/components/checkbox-examples.tsx b/src/components/checkbox-examples.tsx new file mode 100644 index 0000000..f8a4951 --- /dev/null +++ b/src/components/checkbox-examples.tsx @@ -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 ( +
+ {children(id)} + +
+ ) +} + +// 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 ( +
+ {states.map((state) => ( + + ))} +
+ ) +} + +// The wrapping Long Label example (former Usage section of the representative page), also used by the hidden `LongLabel` Story. +export function LongLabelExample() { + return ( +
+
+ + +
+
+ ) +} + +// 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("indeterminate") + return ( + + ) +} + +// 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 ( + <> + + {(id) => } }, + { id: `${idPrefix}checked`, label: "Checked", children: }, + { id: `${idPrefix}indeterminate`, label: "Indeterminate", children: }, + { + id: `${idPrefix}hover`, + label: "Hover", + // data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts). + children: ( +
+ + +
+ ), + }, + { id: `${idPrefix}focus`, label: "Focus", children: }, + { + id: `${idPrefix}disabled`, + label: "Disabled", + children: ( +
+ + +
+ ), + }, + ]} + /> +
+ + {(id) => } }]} /> + + + ) +} diff --git a/src/components/checkbox.stories.tsx b/src/components/checkbox.stories.tsx index 038ced0..f4f0a67 100644 --- a/src/components/checkbox.stories.tsx +++ b/src/components/checkbox.stories.tsx @@ -1,61 +1,11 @@ -import { useId, useState, type ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" -import { Checkbox, Label } from "@hm-design/ui" -import { StateBoard, type BoardState } from "@/components/story-board" -import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" +import { Checkbox } from "@hm-design/ui" +import { StoryPage, StoryPageHeader } 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. -// 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. -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 ( -
- {children(id)} - -
- ) -} - -// 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 ( -
- {states.map((state) => ( - - ))} -
- ) -} - -// The wrapping Long Label example (former Usage section of the representative page), also used by the hidden `LongLabel` Story. -function LongLabelExample() { - return ( -
-
- - -
-
- ) -} - -// Starts indeterminate; a click makes it checked (Radix), so the Indeterminate example is also the interactive one. -function Controlled() { - const [checked, setChecked] = useState("indeterminate") - return ( - - ) -} +// Current HM Checkbox. The specimens live in ./checkbox-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 keeps its ID and +// direct URL but is hidden from the sidebar (tag `verification`). const meta = { title: "Components/Form/Checkbox", @@ -73,46 +23,16 @@ const meta = { export default meta type Story = StoryObj +// 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 = { + tags: ["verification"], name: "Checkbox", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - - {(id) => } }, - { id: "checked", label: "Checked", children: }, - { id: "indeterminate", label: "Indeterminate", children: }, - { - id: "hover", - label: "Hover", - // data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts). - children: ( -
- - -
- ), - }, - { id: "focus", label: "Focus", children: }, - { - id: "disabled", - label: "Disabled", - children: ( -
- - -
- ), - }, - ]} - /> -
- - {(id) => } }]} /> - +
), play: async ({ canvasElement }) => { diff --git a/src/components/collapsible-examples.tsx b/src/components/collapsible-examples.tsx new file mode 100644 index 0000000..34560f3 --- /dev/null +++ b/src/components/collapsible-examples.tsx @@ -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) =>
{children}
+ +function Frame({ label, defaultOpen, disabled, children }: { label: string; defaultOpen?: boolean; disabled?: boolean; children: ReactNode }) { + return narrow( + + + {label} + + + {children} + + ) +} + +export const ClosedSpecimen = () => 추가 옵션이 여기에 표시됩니다. +export const OpenSpecimen = () => ( + + 처음부터 열려 있는 영역입니다. + +) +export const DisabledSpecimen = () => ( + + 열 수 없습니다. + +) +export const LongSpecimen = () => ( + +

{longText}

+

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

+ +) + +// 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( + + + + + {/* 2026-11-13: sample content instead of the former `controlled: open = …` debug text; the trigger label shows the state. */} + 펼친 영역의 내용입니다. + + ) +} + +const sections = [ + { id: "closed", label: "Closed", node: }, + { id: "open", label: "Open", node: }, + { id: "disabled", label: "Disabled", node: }, + { id: "long", label: "Long Content", node: }, + { id: "controlled", label: "Button trigger", node: }, +] + +// 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 ( +
+ {states.map((state) => ( + + ))} +
+ ) +} + +// 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[]) => ( + ids.includes(s.id)).map((s) => ({ id: `${idPrefix}${s.id}`, label: s.label, children: s.node }))} /> + ) + return ( + <> + {board(3, "closed", "open", "disabled")} + {board(3, "long", "controlled")} + + ) +} diff --git a/src/components/collapsible.stories.tsx b/src/components/collapsible.stories.tsx index 0e47d4d..aa90e69 100644 --- a/src/components/collapsible.stories.tsx +++ b/src/components/collapsible.stories.tsx @@ -1,101 +1,26 @@ -import { useState, type ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" -import { ChevronDownIcon } from "lucide-react" -import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from "@hm-design/ui" -import { StateBoard, type BoardState } from "@/components/story-board" -import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" +import { Collapsible } from "@hm-design/ui" +import { StoryPage, StoryPageHeader } from "@/components/story-page" +import { ClosedSpecimen, CollapsibleSections, DisabledSpecimen, LongSpecimen, OpenSpecimen } from "./collapsible-examples" -// The representative "Collapsible" Story shows one independent disclosure per state with real, clickable triggers. The same -// sections also exist as Stories tagged `verification` (hidden from the sidebar). Collapsible is a single disclosure: no divider -// system and no animation. - -const longText = "긴 텍스트는 줄바꿈으로 전체를 표시합니다. 공백 없는 긴 문자열도 너비를 밀어내지 않고 줄바꿈합니다. ".repeat(6) - -const narrow = (children: ReactNode) =>
{children}
- -function Frame({ label, defaultOpen, disabled, children }: { label: string; defaultOpen?: boolean; disabled?: boolean; children: ReactNode }) { - return narrow( - - - {label} - - - {children} - - ) -} - -const ClosedSpecimen = () => 추가 옵션이 여기에 표시됩니다. -const OpenSpecimen = () => ( - - 처음부터 열려 있는 영역입니다. - -) -const DisabledSpecimen = () => ( - - 열 수 없습니다. - -) -const LongSpecimen = () => ( - -

{longText}

-

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

- -) - -// 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( - - - - - {`controlled: open = ${String(open)}`} - - ) -} - -const sections = [ - { id: "closed", label: "Closed", node: }, - { id: "open", label: "Open", node: }, - { id: "disabled", label: "Disabled", node: }, - { id: "long", label: "Long Content", node: }, - { id: "controlled", label: "Button trigger", node: }, -] +// Collapsible is a single disclosure. The specimens live in ./collapsible-examples and are also shown on the Accordion +// 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 meta = { title: "Components/Disclosure/Collapsible", component: Collapsible, parameters: { layout: "padded" } } satisfies Meta export default meta type Story = StoryObj -// 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 -// 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 ( -
- {states.map((state) => ( - - ))} -
- ) -} - -const board = (cols: 2 | 3, ...ids: string[]) => ( - 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. +// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Accordion +// page shows the same sections. export const All: Story = { + tags: ["verification"], name: "Collapsible", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - {board(3, "closed", "open", "disabled")} - {board(3, "long", "controlled")} + ), } diff --git a/src/components/combobox.stories.tsx b/src/components/combobox.stories.tsx index 82f9bdf..1130165 100644 --- a/src/components/combobox.stories.tsx +++ b/src/components/combobox.stories.tsx @@ -1,7 +1,6 @@ import { useState, type ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" 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" // 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 }) => ( -
{children}
-) -const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?: boolean; children: ReactNode }) => ( -
- -
-) - // 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 = @@ -151,7 +139,7 @@ export const All: Story = { }, + { id: "empty", render: }, { id: "medium", render: }, ]} /> @@ -165,10 +153,10 @@ export const All: Story = { /> - - - - +
+
+
+
), diff --git a/src/components/command.stories.tsx b/src/components/command.stories.tsx index 77b557b..01b51e3 100644 --- a/src/components/command.stories.tsx +++ b/src/components/command.stories.tsx @@ -59,7 +59,7 @@ function Grouped() { 환경설정 ⌘, - 결제 (사용 불가) + 결제 @@ -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. const Grid = ({ children }: { children: ReactNode }) => (
{children}
diff --git a/src/components/context-menu-examples.tsx b/src/components/context-menu-examples.tsx new file mode 100644 index 0000000..cf3186e --- /dev/null +++ b/src/components/context-menu-examples.tsx @@ -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 ( + + {/* tabIndex lets keyboard users focus the area and open the menu with Shift+F10 / the context-menu key; Radix Trigger is a plain span. */} + + 이 영역에서 오른쪽 클릭 + + + 파일 + + + 열기 ⌘O + + + 이름 바꾸기 F2 + + 붙여넣기 + + + 보기 + + 상태 표시줄 + + + 패널 + + + 위치 + + 상단 + 하단 + 오른쪽 + + + + 더 보기 + + 이메일로 공유 + 링크 복사 + + + + 삭제 + + + ) +} diff --git a/src/components/context-menu.stories.tsx b/src/components/context-menu.stories.tsx index 7d73b46..22ab237 100644 --- a/src/components/context-menu.stories.tsx +++ b/src/components/context-menu.stories.tsx @@ -1,83 +1,21 @@ -import { useState } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" -import { - ContextMenu, - ContextMenuCheckboxItem, - ContextMenuContent, - ContextMenuGroup, - ContextMenuItem, - ContextMenuLabel, - ContextMenuRadioGroup, - ContextMenuRadioItem, - ContextMenuSeparator, - ContextMenuShortcut, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuTrigger, -} from "@hm-design/ui" +import { ContextMenu } from "@hm-design/ui" 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. -// The representative Story is a target area: right click it. Radix owns keyboard navigation, focus return and Escape. - -function Full() { - const [showStatus, setShowStatus] = useState(true) - const [showPanel, setShowPanel] = useState(false) - const [position, setPosition] = useState("bottom") - return ( - - {/* tabIndex lets keyboard users focus the area and open the menu with Shift+F10 / the context-menu key; Radix Trigger is a plain span. */} - - 이 영역에서 오른쪽 클릭 - - - 파일 - - - 열기 ⌘O - - - 이름 바꾸기 F2 - - 붙여넣기 (사용 불가) - - - 보기 - - 상태 표시줄 - - - 패널 - - - 위치 - - 상단 - 하단 - 오른쪽 - - - - 더 보기 - - 이메일로 공유 - 링크 복사 - - - - 삭제 - - - ) -} +// 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. const meta = { title: "Components/Overlay/Context Menu", component: ContextMenu, parameters: { layout: "centered" } } satisfies Meta export default meta type Story = StoryObj -// 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 = { + tags: ["verification"], name: "Context Menu", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( diff --git a/src/components/date-picker-story.css b/src/components/date-picker-story.css new file mode 100644 index 0000000..180d562 --- /dev/null +++ b/src/components/date-picker-story.css @@ -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); + } +} diff --git a/src/components/date-picker.stories.tsx b/src/components/date-picker.stories.tsx index 81d038b..7c639cb 100644 --- a/src/components/date-picker.stories.tsx +++ b/src/components/date-picker.stories.tsx @@ -1,8 +1,9 @@ import { useState, type ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" import { DatePicker } from "@hm-design/ui" -import { StateBoard } from "@/components/story-board" -import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" +import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } 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). // 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 ( - - - - ) -} - -function CustomFormat() { - return ( - - date.toLocaleDateString("ko-KR")} calendarProps={calendarProps} /> - +
+

+ +

+ {children} +
) } @@ -113,32 +109,14 @@ 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 }) => ( -
{children}
-) -const Cell = ({ id, label, children }: { id: string; label: string; children: ReactNode }) => ( -
- -
-) - -// 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 }[] }) { return ( -
-
- {fields.map(({ id, render }) => ( -
- {render} -
- ))} -
+
+ {fields.map(({ id, render }) => ( +
+ {render} +
+ ))}
) } @@ -148,16 +126,18 @@ export default meta type Story = StoryObj // 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 = { name: "Date Picker", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - + }, + { id: "empty", render: }, { id: "medium", render: }, ]} /> @@ -173,11 +153,18 @@ export const All: Story = { /> - - - - +
+ + + + + date.toLocaleDateString("ko-KR")} calendarProps={calendarProps} /> + +
+ + +
), } diff --git a/src/components/dialog.stories.tsx b/src/components/dialog.stories.tsx index ac1e896..a3c74b8 100644 --- a/src/components/dialog.stories.tsx +++ b/src/components/dialog.stories.tsx @@ -6,8 +6,9 @@ import { Input } from "@hm-design/ui" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@hm-design/ui" import { StateBoard, type BoardState } from "@/components/story-board" 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 // `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 @@ -176,16 +177,15 @@ export default meta type Story = StoryObj // 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. -// 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 -// 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 }) { +// 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 +// Dialog (confirmation / warning) specimens from ./alert-dialog-examples, so "대화상자" is the one sidebar item for both; the Alert +// Dialog Stories keep their IDs and direct URLs but are hidden from the sidebar. +// Keep the board's accessible headings and shared indent, with only the triggers visible in the four-column grid. +function SpecimenGrid({ states }: { states: BoardState[] }) { return ( -
+
{states.map((state) => ( - {state.children}
}]} /> + {state.children}
}]} /> ))}
) @@ -216,6 +216,14 @@ export const All: Story = { ]} /> + + }, + { id: "alert-destructive", label: "Destructive", children: }, + ]} + /> + ), } diff --git a/src/components/drawer-examples.tsx b/src/components/drawer-examples.tsx new file mode 100644 index 0000000..cfcc4cb --- /dev/null +++ b/src/components/drawer-examples.tsx @@ -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 ( + + + + + + + {title} + {description ? {description} : null} + + {children} + {actions} + + + ) +} + +const actions = ( + <> + + + + + + + +) + +export function DrawerBasic({ defaultOpen }: { defaultOpen?: boolean }) { + return ( + +

짧은 내용은 내용 높이만큼만 차지합니다.

+ + ) +} + +export function DrawerForm({ defaultOpen }: { defaultOpen?: boolean }) { + return ( + +
+ + +
+ + ) +} + +export function DrawerLong({ defaultOpen }: { defaultOpen?: boolean }) { + return ( + + {Array.from({ length: 30 }, (_, i) => ( +

+ {longText} +

+ ))} + + ) +} + +// 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 ( +
header]:sr-only" : ""}`}> + {states.map((state) => ( + {state.children}
}]} /> + ))} + + ) +} + +// 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 ( + + }, + { id: `${idPrefix}form`, label: "Form", children: }, + { id: `${idPrefix}long-content`, label: "Long Content", children: }, + ]} + /> + + ) +} diff --git a/src/components/drawer.stories.tsx b/src/components/drawer.stories.tsx index e0c32df..c8836cc 100644 --- a/src/components/drawer.stories.tsx +++ b/src/components/drawer.stories.tsx @@ -1,135 +1,30 @@ import type { Meta, StoryObj } from "@storybook/react-vite" -// 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 { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" +import { Drawer } from "@hm-design/ui" +import { StoryPage, StoryPageHeader } from "@/components/story-page" +import { DrawerBasic, DrawerForm, DrawerLong, DrawerSections } from "./drawer-examples" -// Drawer is a modal vaul overlay (Portal). The representative Story opens 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: React.ReactNode - actions?: React.ReactNode -}) { - return ( - - - - - - - {title} - {description ? {description} : null} - - {children} - {actions} - - - ) -} - -const actions = ( - <> - - - - - - - -) - -function Basic({ defaultOpen }: { defaultOpen?: boolean }) { - return ( - -

짧은 내용은 내용 높이만큼만 차지합니다.

- - ) -} - -function FormCase({ defaultOpen }: { defaultOpen?: boolean }) { - return ( - -
- - -
- - ) -} - -function LongCase({ defaultOpen }: { defaultOpen?: boolean }) { - return ( - - {Array.from({ length: 30 }, (_, i) => ( -

- {longText} -

- ))} - - ) -} +// Drawer is a modal vaul overlay (Portal). The specimens live in ./drawer-examples and are also shown on the Sheet +// 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 meta = { title: "Components/Overlay/Drawer", component: Drawer, parameters: { layout: "centered" } } satisfies Meta export default meta type Story = StoryObj -// 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 -// 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 ( -
- {states.map((state) => ( - {state.children}
}]} /> - ))} - - ) -} - -// Representative Story: Overview and Usage are merged into one Usage section (Basic, Form, Long Content). +// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Sheet +// page shows the same section. export const All: Story = { + tags: ["verification"], name: "Drawer", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - - }, - { id: "form", label: "Form", children: }, - { id: "long-content", label: "Long Content", children: }, - ]} - /> - + ), } -export const Open: Story = { tags: ["verification"], render: () => } -export const Form: Story = { tags: ["verification"], render: () => } -export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => } +export const Open: Story = { tags: ["verification"], render: () => } +export const Form: Story = { tags: ["verification"], render: () => } +export const LongContent: Story = { tags: ["verification"], name: "Long Content", render: () => } diff --git a/src/components/dropdown-menu.stories.tsx b/src/components/dropdown-menu.stories.tsx index 51e37d4..359f285 100644 --- a/src/components/dropdown-menu.stories.tsx +++ b/src/components/dropdown-menu.stories.tsx @@ -18,9 +18,12 @@ import { DropdownMenuTrigger, } from "@hm-design/ui" 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 // 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 }) { const [showStatus, setShowStatus] = useState(true) @@ -42,7 +45,7 @@ function Full({ defaultOpen }: { defaultOpen?: boolean }) { 설정 ⌘, - 결제 (사용 불가) + 결제 보기 @@ -78,18 +81,22 @@ const meta = { title: "Components/Overlay/Dropdown Menu", component: DropdownMen export default meta type Story = StoryObj -// 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 = { name: "Dropdown Menu", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - + + + + ), } diff --git a/src/components/hover-card-examples.tsx b/src/components/hover-card-examples.tsx new file mode 100644 index 0000000..0b7e4ce --- /dev/null +++ b/src/components/hover-card-examples.tsx @@ -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 ( + + + + 한맥 프로젝트 + + + +
+

한맥 프로젝트

+

노선과 시설물 정보를 관리하는 프로젝트입니다.

+

담당 김한맥 · 최근 수정 2026-10-02

+
+
+
+ ) +} + +function LongContent() { + return ( + + + + 긴 콘텐츠 + + + +
+

긴 콘텐츠

+

{longText}

+
+
+
+ ) +} + +// 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 ( +
header]:sr-only" : ""}`}> + {states.map((state) => ( + {state.children}
}]} /> + ))} + + ) +} + +// 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 ( + + }, + { id: `${idPrefix}long-content`, label: "Long Content", children: }, + ]} + /> + + ) +} diff --git a/src/components/hover-card.stories.tsx b/src/components/hover-card.stories.tsx index 50ae7ca..da1acb8 100644 --- a/src/components/hover-card.stories.tsx +++ b/src/components/hover-card.stories.tsx @@ -1,83 +1,26 @@ import type { Meta, StoryObj } from "@storybook/react-vite" -import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hm-design/ui" -import { StateBoard, type BoardState } from "@/components/story-board" -import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" +import { HoverCard } from "@hm-design/ui" +import { StoryPage, StoryPageHeader } 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 or focus to open); the verification Story starts one open. Radix owns the open / close delay and the hover bridge. - -const longText = "긴 콘텐츠는 카드 폭에서 줄바꿈되고 카드 높이만 늘어납니다. 공백 없는 긴 문자열도 폭을 밀어내지 않습니다. ".repeat(4) - -function Preview({ defaultOpen }: { defaultOpen?: boolean }) { - return ( - - - - 한맥 프로젝트 - - - -
-

한맥 프로젝트

-

노선과 시설물 정보를 관리하는 프로젝트입니다.

-

담당 김한맥 · 최근 수정 2026-10-02

-
-
-
- ) -} - -function LongContent() { - return ( - - - - 긴 콘텐츠 - - - -
-

긴 콘텐츠

-

{longText}

-
-
-
- ) -} +// Hover Card is a Radix overlay (Portal) for a richer preview than Tooltip. The specimens live in ./hover-card-examples and +// 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 meta = { title: "Components/Overlay/Hover Card", component: HoverCard, parameters: { layout: "centered" } } satisfies Meta export default meta type Story = StoryObj -// 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 -// 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 ( -
- {states.map((state) => ( - {state.children}
}]} /> - ))} - - ) -} - -// Representative Story: Overview and Usage are merged into one Variants section (Basic, Long Content). +// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Tooltip +// page shows the same section. export const All: Story = { + tags: ["verification"], name: "Hover Card", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - - }, - { id: "long-content", label: "Long Content", children: }, - ]} - /> - + ), } @@ -87,7 +30,7 @@ export const Open: Story = { parameters: { layout: "fullscreen" }, render: () => (
- +
), } diff --git a/src/components/input.stories.tsx b/src/components/input.stories.tsx index e86c3f7..6a37a38 100644 --- a/src/components/input.stories.tsx +++ b/src/components/input.stories.tsx @@ -163,7 +163,9 @@ export const All: Story = { diff --git a/src/components/menubar.stories.tsx b/src/components/menubar.stories.tsx index 0686195..ee9b7cc 100644 --- a/src/components/menubar.stories.tsx +++ b/src/components/menubar.stories.tsx @@ -37,7 +37,7 @@ function Specimen() { 열기 ⌘O - 저장 (사용 불가) + 저장 내보내기 diff --git a/src/components/pagination.stories.tsx b/src/components/pagination.stories.tsx index 55e9209..7546741 100644 --- a/src/components/pagination.stories.tsx +++ b/src/components/pagination.stories.tsx @@ -81,7 +81,7 @@ type Story = StoryObj // 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 -// 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. const cellAlignLeft = "w-full [&_[data-slot=pagination]]:justify-start" diff --git a/src/components/popover.stories.tsx b/src/components/popover.stories.tsx index 2184eee..afec354 100644 --- a/src/components/popover.stories.tsx +++ b/src/components/popover.stories.tsx @@ -75,15 +75,12 @@ const meta = { title: "Components/Overlay/Popover", component: Popover, paramete export default meta type Story = StoryObj -// 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 -// 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 }) { +// Keep the board's accessible headings and shared indent, with only the triggers visible in the four-column grid. +function SpecimenGrid({ states }: { states: BoardState[] }) { return ( -
+
{states.map((state) => ( - {state.children}
}]} /> + {state.children}
}]} /> ))} ) diff --git a/src/components/progress.stories.tsx b/src/components/progress.stories.tsx index a223eae..1878745 100644 --- a/src/components/progress.stories.tsx +++ b/src/components/progress.stories.tsx @@ -50,7 +50,7 @@ export default meta type Story = StoryObj // 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. const specimenColumns = { 2: "min-[1000px]:grid-cols-2", diff --git a/src/components/radio-group-examples.tsx b/src/components/radio-group-examples.tsx new file mode 100644 index 0000000..8127e96 --- /dev/null +++ b/src/components/radio-group-examples.tsx @@ -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 }) { + return ( +
+ {states.map((state) => + spans?.[state.id] ? ( +
+ +
+ ) : ( + + ), + )} +
+ ) +} + +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). + + {["a", "b", "c"].map((v) => ( +
+ + +
+ ))} +
+ ) +} + +// 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. */} + + }, + { id: `${idPrefix}horizontal`, label: "Horizontal", children: }, + ]} + /> + + + }, + { id: `${idPrefix}focus`, label: "Focus", children: }, + { id: `${idPrefix}disabled-item`, label: "Disabled · 항목", children: }, + { id: `${idPrefix}disabled-group`, label: "Disabled · 그룹", children: }, + ]} + /> + + + {/* Same 4-column track as States, so the Error example keeps the width of one States column. */} + }]} /> + + + ) +} diff --git a/src/components/radio-group.stories.tsx b/src/components/radio-group.stories.tsx index 559fc9b..59eecbc 100644 --- a/src/components/radio-group.stories.tsx +++ b/src/components/radio-group.stories.tsx @@ -1,66 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite" -import { Label, RadioGroup, RadioGroupItem } from "@hm-design/ui" -import type { CSSProperties } from "react" -import { StateBoard, type BoardState } from "@/components/story-board" -import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" +import { RadioGroup } from "@hm-design/ui" +import { StoryPage, StoryPageHeader } from "@/components/story-page" +import { Group, RadioGroupSections } from "./radio-group-examples" -// 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 - -function SpecimenGrid({ states, columns, min = 220 }: { states: BoardState[]; columns: keyof typeof specimenColumns; min?: number }) { - return ( -
- {states.map((state) => ( - - ))} -
- ) -} - -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 ( - - {["a", "b", "c"].map((v) => ( -
- - -
- ))} -
- ) -} +// 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 +// keeps its ID and direct URL but is hidden from the sidebar (tag `verification`). const meta = { title: "Components/Form/Radio Group", @@ -78,38 +23,16 @@ const meta = { export default meta type Story = StoryObj +// 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 = { + tags: ["verification"], name: "Radio Group", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - {/* Overview is merged into Variants: its Vertical example is the first Variants item. */} - - }, - { id: "horizontal", label: "Horizontal", children: }, - ]} - /> - - - }, - { id: "focus", label: "Focus", children: }, - { id: "disabled-item", label: "Disabled · 항목", children: }, - { id: "disabled-group", label: "Disabled · 그룹", children: }, - ]} - /> - - - {/* Same 4-column track as States, so the Error example keeps the width of one States column. */} - }]} /> - + ), play: async ({ canvasElement }) => { diff --git a/src/components/resizable.stories.tsx b/src/components/resizable.stories.tsx index 2111e9c..933f974 100644 --- a/src/components/resizable.stories.tsx +++ b/src/components/resizable.stories.tsx @@ -100,7 +100,7 @@ export default meta type Story = StoryObj // 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). function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) { return ( diff --git a/src/components/scroll-area.stories.tsx b/src/components/scroll-area.stories.tsx index 6b41bd6..62c5f54 100644 --- a/src/components/scroll-area.stories.tsx +++ b/src/components/scroll-area.stories.tsx @@ -78,7 +78,7 @@ export default meta type Story = StoryObj // 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. const specimenColumns = { 2: "min-[1000px]:grid-cols-2", diff --git a/src/components/selection-controls-story.css b/src/components/selection-controls-story.css new file mode 100644 index 0000000..a2d6204 --- /dev/null +++ b/src/components/selection-controls-story.css @@ -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; + } +} diff --git a/src/components/selection-controls.stories.tsx b/src/components/selection-controls.stories.tsx new file mode 100644 index 0000000..1fd9630 --- /dev/null +++ b/src/components/selection-controls.stories.tsx @@ -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 ./-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 " ·
" 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 + +// 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
{children}
+} + +// A specimen with its own small caption (Radio Group Disabled 항목 / 그룹, the Variants). +function Captioned({ id, label, children }: { id?: string; label: string; children: ReactNode }) { + return ( +
+ + {label} + + {children} +
+ ) +} + +// 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> }[] = [ + { + id: "checkbox", + label: "Checkbox", + cells: { + default: ( + + + + + ), + hover: ( + + + + + ), + focus: , + disabled: ( + + + + + ), + validation: , + }, + }, + { + id: "radio-group", + label: "Radio Group", + cells: { + default: , + hover: , + // 2026-11-13: the ring is pinned on Option A (data-sb-focus); only the Checkbox specimen holds the page focus. + focus: , + disabled: ( + + + + + + + + + ), + // 2026-11-13 (user request): no option selected, so the Error row shows no green selected mark. + validation: , + }, + }, + { + id: "switch", + label: "Switch", + cells: { + default: ( + + {(id) => } + {(id) => } + + ), + hover: ( + + {(id) => } + {(id) => } + + ), + focus: {(id) => }, + disabled: ( + + {(id) => } + {(id) => } + + ), + }, + }, +] + +// 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 ( +
+ {/* Row headers: the matrix only; in the stacked layout each cell names its own state. */} + {states.map(([key, label], r) => ( +
+ {label} +
+ ))} + {columns.map((column, c) => ( +
+

+ {column.label} +

+ {states.map(([key, label], r) => { + const content = column.cells[key] + if (!content) { + return ( +
+ — + {`${column.label} ${label}: 해당 상태 없음`} +
+ ) + } + return ( +
+ + {label} + + {content} +
+ ) + })} +
+ ))} +
+ ) +} + +function Variants() { + return ( +
+
+

Checkbox

+ + + +
+
+

Radio Group

+ + + + + + + + +
+
+ ) +} + +// 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: () => ( + + +
+
+ + + + + + +
+
+
+ ), + 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('[data-overview-state="checkbox-focus"] [role="checkbox"]')?.focus({ preventScroll: true }) + }, +} diff --git a/src/components/separator.stories.tsx b/src/components/separator.stories.tsx index fc40bdf..bd02338 100644 --- a/src/components/separator.stories.tsx +++ b/src/components/separator.stories.tsx @@ -21,7 +21,7 @@ export default meta type Story = StoryObj // 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. const specimenColumns = { 2: "min-[1000px]:grid-cols-2", diff --git a/src/components/sheet.stories.tsx b/src/components/sheet.stories.tsx index 22a3a3e..0f45349 100644 --- a/src/components/sheet.stories.tsx +++ b/src/components/sheet.stories.tsx @@ -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 { StateBoard, type BoardState } from "@/components/story-board" 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 // 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 type Story = StoryObj -// 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 -// 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 }) { +// Keep the board's accessible headings and shared indent, with only the triggers visible in the four-column grid. +function SpecimenGrid({ states }: { states: BoardState[] }) { return ( -
+
{states.map((state) => ( - {state.children}
}]} /> + {state.children}
}]} /> ))} ) } // 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 = { name: "Sheet", parameters: { layout: "fullscreen", controls: { disable: true } }, @@ -133,6 +133,7 @@ export const All: Story = { ]} /> + ), } diff --git a/src/components/skeleton.stories.tsx b/src/components/skeleton.stories.tsx index 9d2141c..7906ec8 100644 --- a/src/components/skeleton.stories.tsx +++ b/src/components/skeleton.stories.tsx @@ -54,7 +54,7 @@ export default meta type Story = StoryObj // 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. const specimenColumns = { 2: "min-[1000px]:grid-cols-2", diff --git a/src/components/slider-story.css b/src/components/slider-story.css new file mode 100644 index 0000000..3d2d247 --- /dev/null +++ b/src/components/slider-story.css @@ -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; + } +} diff --git a/src/components/slider.stories.tsx b/src/components/slider.stories.tsx index 047b606..018a78a 100644 --- a/src/components/slider.stories.tsx +++ b/src/components/slider.stories.tsx @@ -1,39 +1,41 @@ import { useState, type ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" import { Slider } from "@hm-design/ui" -import { StateBoard } from "@/components/story-board" 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 // `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. -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 ( -
-
- {label} - {valueText} + +
+ + {valueText !== undefined && {valueText}}
{children} -
+ ) } -function Single() { +function Single({ example = false }: { example?: boolean }) { const [value, setValue] = useState([40]) return ( - + ) } -function Range() { +function Range({ example = false }: { example?: boolean }) { const [value, setValue] = useState([25, 75]) return ( - - + + ) } @@ -41,7 +43,7 @@ function Range() { function Step() { const [value, setValue] = useState([50]) return ( - + ) @@ -59,47 +61,31 @@ function Vertical() { ) } -const sections = [ - { id: "single", label: "Single", node: }, - { id: "range", label: "Range", node: }, - { id: "step", label: "Step", node: }, - { id: "disabled", label: "Disabled", node: }, - { id: "vertical", label: "Vertical", node: }, -] - const meta = { title: "Components/Form/Slider", component: Slider, parameters: { layout: "padded" } } satisfies Meta export default meta type Story = StoryObj -// 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 ( -
- {ids.map((id) => { - const s = sections.find((section) => section.id === id)! - return - })} -
- ) -} - -// 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 = { name: "Slider", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - - - - - - +
+ + + + + + + + + + + + + +
), } diff --git a/src/components/story-board.tsx b/src/components/story-board.tsx index 2c7de18..3e5bc8b 100644 --- a/src/components/story-board.tsx +++ b/src/components/story-board.tsx @@ -10,7 +10,7 @@ export type BoardState = { id: string; label: string; note?: string; noteAfter?: // `fixed` uses columns of exactly `min` px instead of stretching them (1fr). Specimens whose popup is anchored to a Radix // trigger need whole-pixel columns: a fractional column (e.g. 358.4px) puts the trigger edge between device pixels while the // popper rounds the popup to whole pixels, so the two outlines end up 1px apart. -// The labels are H3 (14/20/500, secondary) under the section's divider: they carry no divider of their own, and rows sit +// The labels are H3 (14/20/500, secondary) under the section title: they carry no divider of their own, and rows sit // 24px apart (one step tighter than the 28px section gap of story-page.css). // 2026-11-11: inside a StorySection the board is marked `data-sb-board`; story-page.css then indents, once, the block directly // under the section body that is or holds the board (16px / 12px at 640px and below, --sb-page-indent). Stories that put one diff --git a/src/components/story-page.css b/src/components/story-page.css index b2ee0d8..3e3986a 100644 --- a/src/components/story-page.css +++ b/src/components/story-page.css @@ -1,9 +1,9 @@ /* Storybook presentation only. Common page frame for representative Stories; all selectors stay inside .sb-page. 2026-11-11 compact document rhythm, shared by Web / Chart / OrgChart (SW and CENTER pages use the same frame only): 2026-11-11: the H1 page title / description header is no longer shown (the H1 stays for screen readers only); pages start with the - H2 section title 16/24/700 (body-md) → 12px → thin divider → 16px → content; sections 28px apart (2026-11-11 rhythm, first applied + H2 section title 16/24/700 (body-md) → 12px (no divider or 16px margin since 2026-11-13) → content; sections 28px apart (2026-11-11 rhythm, first applied on Foundation / 폰트); H3 specimen labels 14/20/500 secondary, no divider of their own (story-board.tsx). Top / bottom gutter 16–24px. - Side margins are unchanged (16px up to 480px, growing to 60px at 768px): the SW breakpoints 2040 / 1125 / 787 depend on them. + Side margins: 16px up to 480px, growing to 30px at 768px (60px before 2026-11-13): the SW breakpoints 1980 / 1065 / 727 depend on them. Only information blocks (text, spec tables) opt in to the content indent (.sb-page-indent); previews stay on the section edge, except group boards (H3 label + its specimens, marked data-sb-board by StateBoard / CardBoard), indented once per section block below. No design token is added; --sb-page-* only names this Storybook frame's values (component token values or plain px). */ @@ -23,9 +23,10 @@ .sb-page > .sb-page-inner { box-sizing: border-box; /* 2026-11-05 (user request): no max width; the Inner follows the page width. Side margin (each side) = 16px up to a - 480px viewport (358px Inner at 390px, so a 314px specimen fits), then linear to 60px at 768px (44px over 288px, - 11 / 72 of the viewport growth), 60px from 768px. The Inner keeps growing (about 0.69px per 1px), with no jump. */ - width: calc(100% - 2 * clamp(16px, calc(16px + (100vw - 480px) * 11 / 72), 60px)); + 480px viewport (358px Inner at 390px, so a 314px specimen fits), then linear to the maximum at 768px, the maximum from 768px. + 2026-11-13 (user request, with the 180px LNB): maximum 60px → 30px (14px over 288px, 7 / 144 of the viewport growth); + the 16px mobile margin is unchanged. The Inner keeps growing (about 0.9px per 1px), with no jump. */ + width: calc(100% - 2 * clamp(16px, calc(16px + (100vw - 480px) * 7 / 144), 30px)); min-width: 0; margin-inline: auto; } @@ -48,10 +49,11 @@ } .sb-page .sb-page-section { margin-top: var(--sb-page-section-gap); scroll-margin-top: 16px; } +/* 2026-11-13 (user request, all Story pages): the thin divider under the section title is removed, and so is the 16px margin + that sat under it, so the title-to-content spacing is the header's own 12px padding. Every other border stays. */ .sb-page .sb-page-section-header { padding-bottom: 12px; - margin-bottom: 16px; - border-bottom: 1px solid var(--hm-sem-stroke-input-default); + margin-bottom: 0; overflow-wrap: anywhere; } .sb-page .sb-page-section-header .sb-page-section-title { diff --git a/src/components/switch-examples.tsx b/src/components/switch-examples.tsx new file mode 100644 index 0000000..5271ab4 --- /dev/null +++ b/src/components/switch-examples.tsx @@ -0,0 +1,76 @@ +import { useId, type ReactNode } from "react" +import { Label, Switch } from "@hm-design/ui" +import { StateBoard, type BoardState } from "@/components/story-board" +import { StorySection } from "@/components/story-page" + +// Shared Switch specimens (Storybook only). Used by the Switch Stories (verification) and by the Selection Controls +// representative page ("선택 컨트롤"), which shows Checkbox, Radio Group and Switch under one sidebar item. + +// 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 Switch (`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)]" + +export function Option({ label, children }: { label: string; children: (id: string) => ReactNode }) { + const id = useId() + return ( +
+ {children(id)} + +
+ ) +} + +// Representative-page layout (Storybook presentation only): 5 equal columns on PC (iframe width 1000px and up), shrinking +// to fit below that (150px 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. +// `className` replaces the track (the Selection Controls page passes its shared 4-column grid). +function StateGrid({ states, className }: { states: BoardState[]; className?: string }) { + return ( +
+ {states.map((state) => ( + + ))} +
+ ) +} + +// The Switch 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 Switch page keeps its own. +export function SwitchSections({ titlePrefix, idPrefix = "", gridClassName }: { titlePrefix?: string; idPrefix?: string; gridClassName?: string }) { + const title = (name: string) => (titlePrefix ? `${titlePrefix} · ${name}` : name) + return ( + + {(id) => } }, + { id: `${idPrefix}on`, label: "On", children: }, + { + id: `${idPrefix}hover`, + label: "Hover", + // data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts). + children: ( +
+ + +
+ ), + }, + { id: `${idPrefix}focus`, label: "Focus", children: }, + { + id: `${idPrefix}disabled`, + label: "Disabled", + children: ( +
+ + +
+ ), + }, + ]} + /> +
+ ) +} diff --git a/src/components/switch.stories.tsx b/src/components/switch.stories.tsx index 09c85cb..6742d10 100644 --- a/src/components/switch.stories.tsx +++ b/src/components/switch.stories.tsx @@ -1,36 +1,11 @@ -import { useId, type ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" -import { Label, Switch } from "@hm-design/ui" -import { StateBoard, type BoardState } from "@/components/story-board" -import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" +import { Switch } from "@hm-design/ui" +import { StoryPage, StoryPageHeader } from "@/components/story-page" +import { Option, SwitchSections } from "./switch-examples" -// 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 Switch (`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 ( -
- {children(id)} - -
- ) -} - -// Representative-page layout (Storybook presentation only): 5 equal columns on PC (iframe width 1000px and up), shrinking -// to fit below that (150px 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. -function StateGrid({ states }: { states: BoardState[] }) { - return ( -
- {states.map((state) => ( - - ))} -
- ) -} +// Current HM Switch. The specimens live in ./switch-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 keeps its ID and +// direct URL but is hidden from the sidebar (tag `verification`). const meta = { title: "Components/Form/Switch", @@ -48,42 +23,16 @@ const meta = { export default meta type Story = StoryObj +// 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 = { + tags: ["verification"], name: "Switch", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - - {(id) => } }, - { id: "on", label: "On", children: }, - { - id: "hover", - label: "Hover", - // data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts). - children: ( -
- - -
- ), - }, - { id: "focus", label: "Focus", children: }, - { - id: "disabled", - label: "Disabled", - children: ( -
- - -
- ), - }, - ]} - /> -
+
), play: async ({ canvasElement }) => { diff --git a/src/components/table.stories.tsx b/src/components/table.stories.tsx index 0c406b7..9459b06 100644 --- a/src/components/table.stories.tsx +++ b/src/components/table.stories.tsx @@ -145,7 +145,7 @@ export default meta type Story = StoryObj // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that -// (400px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider +// (400px 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 default 520px Table minimum would make the 445px columns scroll sideways, so Basic, Selected row and Long text reset the // documented `--hm-table-min-width` token for their own cell (same as the Tabs usage panel): the table then follows its column diff --git a/src/components/tabs.stories.tsx b/src/components/tabs.stories.tsx index 0ec7b3f..bf248c7 100644 --- a/src/components/tabs.stories.tsx +++ b/src/components/tabs.stories.tsx @@ -9,7 +9,7 @@ import { StateBoard, type BoardState } from "@/components/story-board" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" // Representative "Tabs" Story shows default Primary, explicit API comparisons and Secondary; Legacy stays verification-only. -// D-01 applies to every Fill specimen: flat panels use the actual parent surface and padding 24px 0. +// Fill panels use the actual parent surface without a nested box. The representative page sets its own content padding. // D-02 is shipped by the public Tabs theme mapping (Primary top White alpha .8). Legacy stays compatibility-only. type Variant = "primary" | "secondary" @@ -21,6 +21,8 @@ const variantProps = { secondary: { listProps: { fill: true, panelSurface: "surface" }, surface: "bg-card" }, } as const +const previewPanelSpacing = "px-4 pt-3 pb-6" + function Specimen({ variant = "primary", defaultValue = "menu2", @@ -28,6 +30,7 @@ function Specimen({ count = 3, labels, listProps, + panelSpacing = "px-0 py-6", }: { variant?: Variant defaultValue?: string @@ -35,6 +38,7 @@ function Specimen({ count?: number labels?: string[] listProps?: ListProps + panelSpacing?: string }) { const { listProps: defaults, surface } = variantProps[variant] const effectiveListProps: ListProps = listProps ?? defaults @@ -51,7 +55,7 @@ function Specimen({ ))} {items.map(({ value }, i) => ( - + 메뉴 {i + 1} 콘텐츠 ))} @@ -67,13 +71,16 @@ function SurfaceUsage({ inCard = false }: { inCard?: boolean }) { const id = useId() const [project, setProject] = useState("") const [purpose, setPurpose] = useState("") + // 2026-11-13: both examples use the same panel inset (16 / 12 / 24). In the Card, the Card / CardContent padding stays as is; + // the extra inset applies only to the panel below the tab header, so the header keeps its size and position. + const panelSpacing = previewPanelSpacing const tabs = ( 기본 정보 일정·산출물 - +
@@ -88,7 +95,7 @@ function SurfaceUsage({ inCard = false }: { inCard?: boolean }) { {/* 2026-10-23: this panel can be narrower than the Table default 520px minimum (--hm-table-min-width), which made the table container scroll. The documented token is reset for this panel only: the table follows the panel width and long cell text wraps (Table overflow-wrap). */} - +
산출물마감일화면 설계서10월 14일 @@ -127,17 +134,18 @@ function LongLabelSpecimens() { ) } -// Equal two columns on PC (one column below 900px). Each board holds a single specimen: StateBoard's own auto-fill grid -// would leave an empty third column for two items, so `min` is forced above any column width (same approach as Button). +// 2026-11-13 (user request): Variants, Behavior and Usage share one grid, equal two columns (24px gap) when the section's +// actual content width is 720px or more, one column below. The breakpoint is a container query on the wrapper (its content box, +// after the common board indent), so it follows the Storybook preview's usable width, not the iframe viewport. +// Each board holds a single specimen: StateBoard's own auto-fill grid would leave an empty column for two items, so `min` is +// forced above any column width (same approach as Button). A single child (Behavior) takes the left column; `min-w-0` lets the +// Overflow Tabs keep its own internal scroll (previous / next buttons) inside its column instead of widening the page. const TwoColumns = ({ children }: { children: ReactNode }) => ( -
{children}
+
+
{children}
+
) const SingleBoard = ({ states }: { states: BoardState[] }) => -// Variants: equal three columns on PC (iframe width 1000px and up); fewer columns below that (240px minimum). `min-w-0` lets the -// Overflow Tabs keep its own internal scroll (previous / next buttons) inside its column instead of widening the page. -const ThreeColumns = ({ children }: { children: ReactNode }) => ( -
{children}
-) const meta = { title: "Components/Navigation/Tabs", @@ -147,7 +155,7 @@ const meta = { docs: { description: { component: - "기본 TabsList는 Primary(fill=true, panelSurface=\"content\")입니다. 대표 Story는 기본 Primary, Secondary(fill + panelSurface=\"surface\"), Primary Overflow를 비교하며 명시적 Primary는 숨김 Default API에서 확인합니다. 모든 Fill 예제는 중첩 박스 없는 TabsContent(px-0 py-6)를 사용하며 Usage는 Card 내부 surface와 페이지 content 위의 조합입니다. panelSurface는 List의 선택 면만 바꾸므로 실제 상위 배경에 맞춰 선택합니다. Primary 선택 탭의 gradient는 상단 White alpha 0.8 → content이며 전체 opacity는 1입니다. Secondary는 기존 불투명 White를 유지합니다. Legacy non-fill은 fill={false}로 유지하며 숨김 Compatibility에서 확인합니다. 높이 34, 좌우 16, 상단 radius 4, 16/24/700과 8px 곡선 연결은 기존 값 그대로입니다. Hover는 비선택에만 Black 20%이며 넘칠 때만 이전/다음 이동 버튼이 나타납니다. aria-label과 연결된 입력 Label을 제공하고, 방향키·Home/End·포커스·Disabled·모바일 내부 스크롤을 확인합니다. 곡선 반경 8px과 비선택 합성색의 과거 REVIEW / TEMPORARY 이력은 유지합니다.", + "기본 TabsList는 Primary(fill=true, panelSurface=\"content\")입니다. 대표 Story는 Variants에서 기본 Primary와 Secondary(fill + panelSurface=\"surface\")를 비교하고 Behavior에서 Primary Overflow를 확인하며 명시적 Primary는 숨김 Default API에서 확인합니다. 대표 Story의 평면 패널은 좌우 16px, 상단 12px, 하단 24px 여백을 사용합니다. Usage의 Card 예제도 기존 Card 여백은 그대로 두고 탭 헤더 아래 패널에 같은 좌우 16px, 상단 12px, 하단 24px 여백을 적용합니다. panelSurface는 List의 선택 면만 바꾸므로 실제 상위 배경에 맞춰 선택합니다. Primary 선택 탭의 gradient는 상단 White alpha 0.8 → content이며 전체 opacity는 1입니다. Secondary는 기존 불투명 White를 유지합니다. Legacy non-fill은 fill={false}로 유지하며 숨김 Compatibility에서 확인합니다. 높이 34, 좌우 16, 상단 radius 4, 16/24/700과 8px 곡선 연결은 기존 값 그대로입니다. Hover는 비선택에만 Black 20%이며 넘칠 때만 이전/다음 이동 버튼이 나타납니다. aria-label과 연결된 입력 Label을 제공하고, 방향키·Home/End·포커스·Disabled·모바일 내부 스크롤을 확인합니다. 곡선 반경 8px과 비선택 합성색의 과거 REVIEW / TEMPORARY 이력은 유지합니다.", }, }, }, @@ -159,16 +167,19 @@ export const All: Story = { name: "Tabs", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - // The common compact Story page (16–24px gutter, 24px section gap) fits this page on a 900px-high PC screen without an override. + // The common compact Story page (16–24px gutter, 28px section gap) fits this page on a 900px-high PC screen without an override. - {/* The former States section (Overflow) is the third Variants item. */} - - }]} /> - }]} /> - }]} /> - + + }]} /> + }]} /> + + + + + }]} /> + diff --git a/src/components/toggle-examples.tsx b/src/components/toggle-examples.tsx new file mode 100644 index 0000000..8c775ae --- /dev/null +++ b/src/components/toggle-examples.tsx @@ -0,0 +1,194 @@ +import { BoldIcon, ItalicIcon, StarIcon } from "lucide-react" +import { Toggle, ToggleGroup, ToggleGroupItem } from "@hm-design/ui" +import { StateBoard, type BoardState } from "@/components/story-board" +import { StorySection, StorySectionGrid } from "@/components/story-page" + +// Shared Toggle specimens (Storybook only). Used by the Toggle Stories (verification) and by the Button representative page, +// which shows Button and Toggle Button under one sidebar item ("버튼"). +// Toggle is a persistent pressed-state control. The sections show each state with real, clickable toggles; the same +// specimens also exist as Stories tagged `verification` (hidden from the sidebar). Icon-only toggles carry an `aria-label`. +// 2026-11-12: toggles side by side keep the Button Story's 8px gap (was 12px). + +export function OffOn() { + return ( +
+ Off + On + Outline off + + Outline on + +
+ ) +} + +export function WithIcon() { + return ( +
+ + + + + + + + + 즐겨찾기 + +
+ ) +} + +const toggleSizes = ["sm", "default", "lg"] as const + +function SizeRow({ size }: { size: (typeof toggleSizes)[number] }) { + return ( +
+ {/* 2026-11-13: the text-only toggle shows sample text instead of the size prop value (the row label names the size). */} + + 즐겨찾기 + 알림 +
+ ) +} + +function Sizes() { + return ( + // 3 equal columns on PC (iframe width 1000px and up): sm / default / lg; fewer columns below that (240px minimum). + // Same common indent as the group boards of the page (.sb-page-indent). +
+ {toggleSizes.map((size) => )} +
+ ) +} + +export function Disabled() { + return ( +
+ Off + + On + +
+ ) +} + +const block = (state: BoardState) => + +// Representative-page layout (Storybook presentation only). StateBoard's own auto-fill grid cannot give equal columns that +// fill a cell, so each specimen sits in its own single-column StateBoard (min larger than any column) inside a grid. +function SpecimenGrid({ states }: { states: BoardState[] }) { + return ( +
+ {states.map(block)} +
+ ) +} + +// The 단독 / 연결 그룹 specimens, shared by the Toggle page and the Button page. `short` (Button page, 2026-11-13 user +// request): under its "Groups" H3 the labels drop "연결 그룹 · " so the left label column stays narrow; the Toggle page keeps them. +const groupSpecimens = (idPrefix: string, short?: boolean): BoardState[] => [ + // The flex wrapper keeps the Toggle at its own width; as a direct grid child of the board it stretched to the column. + { id: `${idPrefix}single-toggle`, label: "단독", children:
알림
}, + { id: `${idPrefix}group-single`, label: short ? "단일 선택" : "연결 그룹 · 단일 선택", children: 왼쪽가운데오른쪽 }, + { id: `${idPrefix}group-multiple`, label: short ? "다중 선택" : "연결 그룹 · 다중 선택", children: 굵게기울임 }, +] + +// Button page (2026-11-13, user request): one "Toggle Button" H2 section with 3 equal columns — Variants / States, Groups, +// Sizes — laid out like the Button States matrix above (button.stories.tsx StateMatrix). Each column is a Button group block +// (GroupBlock: H3 14/20/500 secondary on the section edge, 8px to its rows). Each row is its label on the left, in the +// matrix row-label style (14/20/500 secondary, one line), 8px, then the controls; rows are 8px apart and the controls of a +// group start on one line (the label column takes the widest label). `stacked` (the Button matrices' stacked layout, shared +// through the Button page's MatrixFit) switches every group with them to the stacked form: the label above (12/16/400 +// secondary, the stacked state name), 4px, then the controls; rows 12px apart. Storybook presentation only. +const rowLabel = "text-sm font-medium text-muted-foreground whitespace-nowrap" +const stackedLabel = "text-xs font-normal text-muted-foreground" + +function ButtonPageGroup({ id, label, items, stacked }: { id: string; label: string; items: BoardState[]; stacked?: boolean }) { + return ( +
+

{label}

+
+ {items.map((item) => ( +
+ + {item.label} + + {item.children} +
+ ))} +
+
+ ) +} + +// Same size names as the Button Sizes (Small / Medium / Large); `default` is the medium size. +const sizeLabels = { sm: "Small", default: "Medium", lg: "Large" } as const + +// Same columns as the Button treatments above: 3 equal columns from a 1400px preview, one column below that. +function ButtonPageToggleSection({ title, idPrefix, stacked }: { title: string; idPrefix: string; stacked?: boolean }) { + return ( + // pt-2: 8px on top of the 28px section gap, so Toggle Button sits 36px below the Button columns. +
+ +
+ }, + { id: `${idPrefix}disabled`, label: "Disabled", children: }, + ]} + /> + + ({ id: `${idPrefix}size-${size}`, label: sizeLabels[size], children: }))} + /> +
+
+
+ ) +} + +// The Toggle 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. +// On PC (iframe width 1000px and up) Variants and States share the whole width in 2 columns, in proportion to the width their +// one-line row needs (324 / 234px incl. the 16px indent), never narrower than that row (max-content), so the 4 Variants toggles +// stay on one line (2026-11-12; they wrapped in the former 3 equal columns at a 1100px preview). Below that the sections stack +// and the toggle rows wrap naturally. +// `flat` (Button page, 2026-11-13): one "Toggle Button" section (`titlePrefix`) in 3 equal columns, ButtonPageToggleSection; +// `stacked` follows the Button matrices' stacked layout. The Toggle page (no `flat`) keeps the separate sections below. +export function ToggleSections({ titlePrefix, idPrefix = "", flat, stacked }: { titlePrefix?: string; idPrefix?: string; flat?: boolean; stacked?: boolean }) { + if (flat) return + const title = (name: string) => (titlePrefix ? `${titlePrefix} · ${name}` : name) + return ( + <> + {/* Each class stays a whole token next to spaces, so Tailwind's source scan still finds the arbitrary grid-cols value. */} + + + {block({ id: `${idPrefix}offon`, label: "Off / On", children: })} + + + {block({ id: `${idPrefix}disabled`, label: "Disabled", children: })} + + + {/* With `titlePrefix` the 단독 · 연결 그룹 title reads "단독/연결 그룹" (no second "·" after the prefix). */} + + + + + + + + ) +} diff --git a/src/components/toggle.stories.tsx b/src/components/toggle.stories.tsx index 70dfdeb..b952e04 100644 --- a/src/components/toggle.stories.tsx +++ b/src/components/toggle.stories.tsx @@ -1,127 +1,27 @@ import type { Meta, StoryObj } from "@storybook/react-vite" -import { BoldIcon, ItalicIcon, StarIcon } from "lucide-react" -import { Toggle, ToggleGroup, ToggleGroupItem } from "@hm-design/ui" -import { StateBoard, type BoardState } from "@/components/story-board" -import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page" +import { StarIcon } from "lucide-react" +import { Toggle } from "@hm-design/ui" +import { StoryPage, StoryPageHeader } from "@/components/story-page" +import { Disabled, OffOn, ToggleSections, WithIcon } from "./toggle-examples" -// Toggle is a persistent pressed-state control. The representative Story shows each state with real, clickable toggles; the same -// specimens also exist as Stories tagged `verification` (hidden from the sidebar). Icon-only toggles carry an `aria-label`. -// 2026-11-12: toggles side by side keep the Button Story's 8px gap (was 12px). - -function OffOn() { - return ( -
- Off - On - Outline off - - Outline on - -
- ) -} - -function WithIcon() { - return ( -
- - - - - - - - - 즐겨찾기 - -
- ) -} - -function Sizes() { - return ( - // 3 equal columns on PC (iframe width 1000px and up): sm / default / lg; fewer columns below that (240px minimum). - // Same common indent as the group boards of the page (.sb-page-indent). -
- {(["sm", "default", "lg"] as const).map((size) => ( -
- - 즐겨찾기 - {size} -
- ))} -
- ) -} - -function Disabled() { - return ( -
- Disabled off - - Disabled on - -
- ) -} - -const sections = [ - { id: "offon", label: "Off / On", node: }, - { id: "disabled", label: "Disabled", node: }, -] +// Toggle is a persistent pressed-state control. The specimens live in ./toggle-examples and are also shown on the Button +// representative page ("버튼", Toggle Button section), the single sidebar item for both. Every Story here keeps its ID and +// direct URL but is hidden from the sidebar (tag `verification`). const meta = { title: "Components/Form/Toggle", component: Toggle, parameters: { layout: "padded" } } satisfies Meta export default meta type Story = StoryObj -// Representative-page layout (Storybook presentation only). StateBoard's own auto-fill grid cannot give equal columns that -// fill a cell, so each specimen sits in its own single-column StateBoard (min larger than any column) inside a grid. -const specimen = (id: string): BoardState => { - const s = sections.find((section) => section.id === id)! - return { id: s.id, label: s.label, children: s.node } -} -function SpecimenGrid({ states }: { states: BoardState[] }) { - return ( -
- {states.map((state) => ( - - ))} -
- ) -} - -// Representative Story: the common Story page; the sections group the same specimens. -// Overview is renamed Variants. On PC (iframe width 1000px and up) Variants and States share the whole width in 2 columns, -// in proportion to the width their one-line row needs (324 / 234px incl. the 16px indent), never narrower than that row -// (max-content), so the 4 Variants toggles stay on one line (2026-11-12; they wrapped in the former 3 equal columns at a -// 1100px preview). Below that the sections stack and the toggle rows wrap naturally. +// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Button +// page shows the same sections. export const All: Story = { + tags: ["verification"], name: "Toggle", parameters: { layout: "fullscreen", controls: { disable: true } }, render: () => ( - - - - - - - - - - 알림 }, - { id: "group-single", label: "연결 그룹 · 단일 선택", children: 왼쪽가운데오른쪽 }, - { id: "group-multiple", label: "연결 그룹 · 다중 선택", children: 굵게기울임 }, - ]} - /> - - - - + ), } diff --git a/src/components/tooltip.stories.tsx b/src/components/tooltip.stories.tsx index e01ed61..81f4c8f 100644 --- a/src/components/tooltip.stories.tsx +++ b/src/components/tooltip.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite" import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hm-design/ui" import { StateBoard, type BoardState } from "@/components/story-board" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" +import { HoverCardSections } from "./hover-card-examples" // Tooltip is a Radix overlay (Portal) with short, non-interactive text. The representative Story shows real triggers // (hover or focus to open); the verification Story starts one open. Interactive content belongs in Popover / Hover Card. @@ -67,21 +68,21 @@ const meta = { title: "Components/Overlay/Tooltip", component: Tooltip, paramete export default meta type Story = StoryObj -// 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 -// 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 }) { +// Keep the board's accessible headings and shared indent, with only the triggers visible in the five-column grid. +function SpecimenGrid({ states }: { states: BoardState[] }) { return ( -
+
{states.map((state) => ( - {state.children}
}]} /> + {state.children}
}]} /> ))} ) } // Representative Story: Overview is merged into Variants (Basic is its first item, then the four sides): 5 columns on PC. +// 2026-11-13 (user request): it also shows the Hover Card section from ./hover-card-examples (same 5-column track), so +// "툴팁·호버 카드" is the one sidebar item for both hover previews; the Hover Card Stories keep their IDs and direct URLs but +// are hidden from the sidebar. export const All: Story = { name: "Tooltip", parameters: { layout: "fullscreen", controls: { disable: true } }, @@ -91,7 +92,6 @@ export const All: Story = { }, { id: "top", label: "Top", children: }, @@ -103,13 +103,13 @@ export const All: Story = { }, { id: "disabled", label: "Disabled", children: }, ]} /> +
), diff --git a/src/foundation/foundation.css b/src/foundation/foundation.css index e2d8329..42ec549 100644 --- a/src/foundation/foundation.css +++ b/src/foundation/foundation.css @@ -56,6 +56,10 @@ .hm-foundation .hm-brand-color-row[data-palette] .hm-brand-color-chip:first-child .hm-foundation-swatch { border-radius: var(--hm-radius-control-md) 0 0 var(--hm-radius-control-md); } .hm-foundation .hm-brand-color-row[data-palette] .hm-brand-color-chip:last-child .hm-foundation-swatch { border-radius: 0 var(--hm-radius-control-md) var(--hm-radius-control-md) 0; } } +/* 2026-11-13: Default stands apart from the Light / Dark steps with a 2px page-background seam on each side. The seam is an + outer shadow drawn over the neighbours, so chip widths, values and order do not change; at a line start it falls on the page. */ +.hm-foundation .hm-brand-color-row[data-palette] .hm-brand-color-chip[data-color-stage="default"] { position: relative; z-index: 1; } +.hm-foundation .hm-brand-color-row[data-palette] .hm-brand-color-chip[data-color-stage="default"] .hm-foundation-swatch { box-shadow: -2px 0 0 var(--hm-sem-bg-content), 2px 0 0 var(--hm-sem-bg-content); } /* Name 14 Bold; role line 12 Regular (Default "기본색" 12 Medium); HEX 12. Dark text on a swatch reads thinner than white, so its role line and HEX use Medium. */ .hm-foundation .hm-brand-color-row:is([data-palette], [data-company-id], [data-text-colors]) h3 { margin: 0; font-size: var(--hm-label-font-size); line-height: var(--hm-label-line-height); font-weight: var(--hm-font-weight-bold); color: inherit; } @@ -84,7 +88,7 @@ .hm-foundation .hm-brand-color-row:is([data-company-id], [data-text-colors]) .hm-foundation-swatch { border-radius: var(--hm-radius-control-md); } /* Typography: minimal type specimen page (presentation only, existing tokens; no card box, outer border or shadow). - Section titles, rhythm (title → 12px → divider → 16px → content, sections 28px apart) and the content indent (.sb-page-indent on + Section titles, rhythm (title → 12px → content, sections 28px apart) and the content indent (.sb-page-indent on Font Family, Font Weight and Specimens; Text Colors chips stay on the edge) come from the common Story page (story-page.css). Text levels: label / token 12/18/400 secondary, family and weight names 14/700 primary, specimen name and metrics 12/18 secondary; previews and samples carry the largest type. */ @@ -97,6 +101,8 @@ the row is one column with the preview first. */ .hm-foundation :is(.hm-typo-rows, .hm-typo-group) { container-type: inline-size; } .hm-foundation .hm-typo-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px 24px; align-items: center; } +/* 2026-11-13: row content sits 12px inside on both sides; the row rules (border-top on the row) keep their start and width. */ +.hm-foundation .hm-typo-row { padding-inline: 12px; } .hm-foundation .hm-typo-row :is(p, code) { margin: 0; } .hm-foundation :is(.hm-typo-row-meta, .hm-typo-row-preview) { display: grid; gap: 2px; min-width: 0; } .hm-foundation .hm-typo-row-preview { order: -1; } @@ -109,8 +115,7 @@ .hm-foundation .hm-typo-kicker { color: var(--hm-sem-fg-secondary); } .hm-foundation .hm-typo-family-name { font-weight: var(--hm-font-weight-bold); } .hm-foundation .hm-typo-row .hm-typo-family-preview { margin-bottom: 4px; font-size: var(--hm-page-header-title-font-size); line-height: var(--hm-page-header-title-line-height); } -/* Font Weight: one row per weight (name + value · token | preview), 12px above and below with a thin rule between rows only - (the section divider sits above the first row). */ +/* Font Weight: one row per weight (name + value · token | preview), 12px above and below with a thin rule between rows only. */ .hm-foundation .hm-typo-weight { padding-block: 12px; } .hm-foundation .hm-typo-weight:first-child { padding-top: 0; } .hm-foundation .hm-typo-weight:last-child { padding-bottom: 0; } diff --git a/src/org-chart/core-component-preview.tsx b/src/org-chart/core-component-preview.tsx index d7ec988..f8ea528 100644 --- a/src/org-chart/core-component-preview.tsx +++ b/src/org-chart/core-component-preview.tsx @@ -57,7 +57,16 @@ export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKi if (kind === 'Logo') { component = createLogoLegend(document, context, controller.signal); // Overview already supplies the section heading; keep the shared logo list intact. - if (hideLogoHeading) component.querySelector(':scope > h2')?.remove(); + if (hideLogoHeading) { + component.querySelector(':scope > h2')?.remove(); + // Without the heading, the list's UA top margin (1em = 13px) would sit under the Storybook section title + // (2026-11-13: title → content stays the common 12px). Package CSS is unchanged. + const list = component.querySelector(':scope > .hm-orgchart-logo-list'); + if (list) list.style.marginTop = '0'; + // 2026-11-13: 가족사 현황 page shows the 7 logo items without a card border. Padding, size and the + // grid gap stay, so items remain separated by space only. Package CSS is unchanged. + list?.querySelectorAll(':scope > li').forEach((item) => { item.style.border = '0'; }); + } } else if (kind === 'DepartmentColors') component = createDepartmentColors(document, context, controller.signal); else if (kind === 'PositionSummary') component = presentation === 'workdesk' ? createOrganizationTotals(document, context) : createPositionSummary(document, context); @@ -89,7 +98,9 @@ export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKi if (compactDepartmentPreview) { // Keep the representative and department cards built by the shared Core. // The Tunnel review frame styles wrappers only; approved artwork stays intact. - shell.classList.add('sb-center', 'hm-orgchart-department-preview'); + // 2026-11-13: `sb-center--plain` drops the review card frame (grey panel, border, header strip), + // like the 노선도 pages; the OrgChart artwork itself is untouched. + shell.classList.add('sb-center', 'sb-center--plain', 'hm-orgchart-department-preview'); const representative = component.querySelector(':scope > .hm-orgchart-representative-preview'); if (representative) { const card = representative.querySelector('[data-card-kind="topLeader"]')!; @@ -139,7 +150,17 @@ export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKi preview.append(header, stage); return preview; }); - component.replaceChildren(...(representative ? [representative] : []), ...rows, ...statusPreviews); + // 2026-11-13: the six department rows form their own titled section (like 대표자/파견자/휴직자), + // keeping their 3-column grid inside it. + const departments = document.createElement('section'); + departments.className = 'tunnel-reference__sample hm-orgchart-departments-section'; + const departmentsHeader = document.createElement('header'); + const departmentsTitle = document.createElement('h2'); departmentsTitle.textContent = '부서별 색상'; + departmentsHeader.append(departmentsTitle); + const departmentRows = document.createElement('div'); departmentRows.className = 'hm-orgchart-department-rows'; + departmentRows.append(...rows); + departments.append(departmentsHeader, departmentRows); + component.replaceChildren(...(representative ? [representative] : []), departments, ...statusPreviews); component.classList.add('hm-orgchart-department-colors-design', 'tunnel-reference__variant-grid'); for (const name of component.querySelectorAll('.hm-orgchart-person-name')) name.setAttribute('aria-label', sampleName); } diff --git a/src/org-chart/department-colors-preview.css b/src/org-chart/department-colors-preview.css index 4bfe34a..775ecf7 100644 --- a/src/org-chart/department-colors-preview.css +++ b/src/org-chart/department-colors-preview.css @@ -1,8 +1,24 @@ -/* Department Colors Storybook adapter only. The actual Tunnel sample/stage - frame comes from center-stories.css; this sheet never selects employee cards. */ -.hm-orgchart-department-preview .hm-orgchart-department-colors-design { +/* Department Colors Storybook adapter only. The specimen wrappers reuse the + center-stories.css classes in their `sb-center--plain` form (no grey panel, + border or header strip); this sheet never selects employee cards. */ + +/* 2026-11-13: Button/Badge page rhythm. Four sections (대표자 / 부서별 색상 / 파견자 / 휴직자) sit + the common section gap apart; 파견자·휴직자 share a row on desktop, columns 24px apart. */ +.sb-center.hm-orgchart-department-preview .hm-orgchart-department-colors-design { grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--sb-center-column-gap); + gap: var(--sb-page-section-gap, 28px) 24px; +} + +.hm-orgchart-department-preview .hm-orgchart-departments-section { + grid-column: 1 / -1; +} + +/* The six department rows keep their 3-column grid, 24px apart, 12px under the section title. */ +.hm-orgchart-department-preview .hm-orgchart-department-rows { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; + padding-top: 12px; } .hm-orgchart-department-preview .hm-orgchart-color-preview { @@ -14,8 +30,17 @@ grid-column: 1 / -1; } +/* Section titles: the common StorySection title (16/24/700, primary); department bars are not selected. */ +.sb-center.hm-orgchart-department-preview :is(.hm-orgchart-representative-preview, .hm-orgchart-departments-section, .hm-orgchart-status-preview) > header > h2 { + margin: 0; + color: var(--hm-sem-fg-primary); + font-size: var(--sb-page-section-title-font-size, 16px); + line-height: var(--sb-page-section-title-line-height, 24px); + font-weight: var(--hm-font-weight-bold, 700); +} + /* Group titles are OrgChart artwork. Preserve their established typography, - white text and organization gradient within the common review header. */ + white text and organization gradient; the bar spans its column. */ .hm-orgchart-department-preview .hm-orgchart-color-preview > header > .hm-orgchart-color-department-name { box-sizing: border-box; width: 100%; @@ -25,10 +50,11 @@ font-weight: var(--hm-orgchart-resolved-font-weight-bold); } +/* Specimens start on the label's left edge; wide content keeps the stage's local scroll. */ .hm-orgchart-department-preview .hm-orgchart-department-stage { display: grid; - justify-items: safe center; - align-items: center; + justify-items: start; + align-items: start; } .hm-orgchart-department-preview .hm-orgchart-preview-grid { @@ -36,21 +62,20 @@ gap: var(--sb-center-column-gap); } -/* Keep Fixed's status presenter inside the existing review surface, without - introducing the full canvas background or coordinates into the specimen. */ +/* Keep Fixed's status presenter without the full canvas background or coordinates. */ .hm-orgchart-department-preview .hm-orgchart-status-preview-composition { width: max-content; background: transparent; } @media (max-width: 960px) { - .hm-orgchart-department-preview .hm-orgchart-department-colors-design { + .sb-center.hm-orgchart-department-preview :is(.hm-orgchart-department-colors-design, .hm-orgchart-department-rows) { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 760px) { - .hm-orgchart-department-preview .hm-orgchart-department-colors-design { + .sb-center.hm-orgchart-department-preview :is(.hm-orgchart-department-colors-design, .hm-orgchart-department-rows) { grid-template-columns: minmax(0, 1fr); } } diff --git a/src/org-chart/org-chart-expanded-story.css b/src/org-chart/org-chart-expanded-story.css index 1eb3195..70dcd81 100644 --- a/src/org-chart/org-chart-expanded-story.css +++ b/src/org-chart/org-chart-expanded-story.css @@ -1,15 +1,19 @@ /* Existing Expanded Story content layout; HM widget styles stay unchanged. */ -/* Reuse the existing Storybook review card surface through inherited inputs. - Only the outer Auto preview consumes these; its controls/artwork stay intact. */ +/* 2026-11-13: no review card surface (grey panel, border, radius) around the Auto preview; these + inputs only reach the outer Auto container, so the canvas, controls and artwork stay intact. */ .hm-orgchart-story-expanded .hm-orgchart-story-preview.sb-center { - --hm-orgchart-expanded-preview-background: var(--sb-center-card-background); - --hm-orgchart-expanded-preview-border: var(--sb-center-card-border); - --hm-orgchart-expanded-preview-radius: var(--sb-center-card-radius); + --hm-orgchart-expanded-preview-background: transparent; + --hm-orgchart-expanded-preview-border: 0; + --hm-orgchart-expanded-preview-radius: 0; padding: 0; } -/* Preview first: the chart follows the page header with the header's own 24px rhythm. */ +/* 확장 조직도 page only: no page gutters, so the chart uses the whole preview (like 전체 구성). */ +.sb-page.hm-orgchart-story-expanded { padding-block: 0; } +.sb-page.hm-orgchart-story-expanded > .sb-page-inner { width: 100%; margin-inline: 0; } +/* Preview first: the chart is the page's first visible block (the page title is the visually hidden H1), so it starts at the + page's top gutter. 2026-11-13: the former margin-top: 24px was removed; the common first-block reset (story-page.css) already + overrode it (computed 0px), so nothing moves. */ .hm-orgchart-story-expanded .hm-orgchart-story-lead { display: grid; gap: 0.75rem; - margin-top: 24px; } diff --git a/src/org-chart/org-chart-expanded.stories.tsx b/src/org-chart/org-chart-expanded.stories.tsx index ebd294b..1ad4d10 100644 --- a/src/org-chart/org-chart-expanded.stories.tsx +++ b/src/org-chart/org-chart-expanded.stories.tsx @@ -25,8 +25,13 @@ const interactions: OrgChartInteractionOptions = { collapse: true, zoom: true, p // The elements stay in the shadow DOM: the move feedback remains the cards' aria-describedby / live status // (visually hidden), and the OrgChart browser checks still read their text. const previewText = new CSSStyleSheet(); +// 2026-11-13: also hides the chart title and lets the canvas fill the preview height below the toolbar +// (Core's fixed 65vh canvas height and outer padding are overridden here only); toolbar keeps an 8px safe inset. previewText.replaceSync(` - .hm-orgchart-date, .hm-orgchart-expanded-selection { display: none; } + .hm-orgchart-date, .hm-orgchart-expanded-selection, .hm-orgchart-expanded > .hm-orgchart-heading { display: none; } + .hm-orgchart-expanded { height: 100vh; grid-template-rows: auto minmax(0, 1fr); gap: 0; padding: 0; } + .hm-orgchart-expanded > .hm-orgchart-expanded-toolbar { padding: 8px; } + .hm-orgchart-expanded > .hm-orgchart-viewport { height: auto; min-height: 0; } .hm-orgchart-move-feedback { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } `); function hidePreviewText(container: HTMLElement): () => void { @@ -51,11 +56,11 @@ function ExpandedPreview() { return - {/* Preview first: the chart sits right under the page title; its own header already names it, so no section header. */} + {/* Preview first: the chart is the first visible block (the page title is the hidden H1); its own header already names it, so no section header. */}
{error ?

{error}

: null}
- setError(cause instanceof Error ? cause.message : '조직도 설정을 적용할 수 없습니다.')} onStateChange={state => { if (state.status === 'error') setError(state.issues.map(issue => issue.message).join(' / ')); }} /> + setError(cause instanceof Error ? cause.message : '조직도 설정을 적용할 수 없습니다.')} onStateChange={state => { if (state.status === 'error') setError(state.issues.map(issue => issue.message).join(' / ')); }} />
; diff --git a/src/org-chart/org-chart-p1.stories.tsx b/src/org-chart/org-chart-p1.stories.tsx index 55090b0..4a02663 100644 --- a/src/org-chart/org-chart-p1.stories.tsx +++ b/src/org-chart/org-chart-p1.stories.tsx @@ -2,7 +2,7 @@ import { StrictMode } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { OverviewPreview } from './org-chart-page-previews'; -const meta = { title: '조직도/개요', component: OverviewPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => ] } satisfies Meta; +const meta = { title: '조직도/가족사 현황', component: OverviewPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => ] } satisfies Meta; export default meta; type Story = StoryObj; -export const LogoPersonnelSummary: Story = { name: '개요', parameters: { __id: 'orgchart--logo-personnel-summary' } }; +export const LogoPersonnelSummary: Story = { name: '가족사 현황', parameters: { __id: 'orgchart--logo-personnel-summary' } }; diff --git a/src/org-chart/org-chart-page-previews.tsx b/src/org-chart/org-chart-page-previews.tsx index 5066d03..2389d96 100644 --- a/src/org-chart/org-chart-page-previews.tsx +++ b/src/org-chart/org-chart-page-previews.tsx @@ -18,35 +18,33 @@ const compositionStylesheets = { engine: engineUrl, core: coreUrl }; export function CompositionPreview() { const host = useRef(null); useLayoutEffect(() => centerCompositionPreview(host.current!), []); - return + // 2026-11-13: no section title and no page gutters; Core's own width/height fit fills the preview. + return - -
- -
-
+
+ +
; } export function DepartmentTypographyPreview() { return - -
-
+ {/* 2026-11-13: no shared section title; the preview renders its own four section titles. */} +
; } export function OverviewPreview() { return - - + +
- +
; diff --git a/src/org-chart/org-chart-stories.css b/src/org-chart/org-chart-stories.css index 939a6e2..32eb625 100644 --- a/src/org-chart/org-chart-stories.css +++ b/src/org-chart/org-chart-stories.css @@ -17,6 +17,11 @@ .hm-orgchart-story-page .hm-orgchart-story { max-width: none; } .hm-orgchart-story-page .hm-orgchart-story-preview { min-width: 0; max-width: 100%; } +/* 전체 구성 page only (2026-11-13): no page gutters, so Core's proportional + width/height fit uses the whole preview. Other StoryPage layouts are unchanged. */ +.sb-page.hm-orgchart-composition-page { padding-block: 0; } +.sb-page.hm-orgchart-composition-page > .sb-page-inner { width: 100%; margin-inline: 0; } + .hm-orgchart-story-note { margin: 0; overflow-wrap: anywhere; diff --git a/src/sw/sw-footer-stories.css b/src/sw/sw-footer-stories.css index 84f1d9b..660577e 100644 --- a/src/sw/sw-footer-stories.css +++ b/src/sw/sw-footer-stories.css @@ -37,11 +37,10 @@ .sw-footer-story-row--tight { gap: 0; } .sw-footer-story-inline { display: inline-flex; flex-direction: column; padding: 0; } /* Story page frame only (2026-11-11): the width rows sit in a container (.sw-story-responsive), where margins do not collapse, - so the rows own the common section gap (story-page.css) and their sections drop their own top margin. */ + so their sections drop their own top margin. 2026-11-13: the container is also .sw-story-stack (sw-stories.css, as on the + SW Header / Viewer / Step Header pages), whose flex gap (the common section gap) spaces the rows; hidden rows + (sw-story-responsive-hidden, rows wider than the Story column) leave no gap, so the former sibling margins are gone. */ .sb-page .sw-footer-story-width > .sb-page-section { margin-top: 0; } -.sw-footer-story-width + .sw-footer-story-width { margin-top: var(--sb-page-section-gap, 24px); } -/* The hidden 1920px row (Responsive More, narrower than 1920px) takes its gap with it. */ -@container sw-story-page (max-width: 2039.98px) { .sw-story-responsive-hidden + .sw-footer-story-width { margin-top: 0; } } .sw-footer-story-aux-compare { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 16px; } .sw-footer-story-aux-row { display: flex; align-items: center; gap: 12px; margin: 0; } .sw-footer-story-aux-row figcaption { flex: 0 0 120px; font-size: 12px; color: #3d4a45; } diff --git a/src/sw/sw-footer.stories.tsx b/src/sw/sw-footer.stories.tsx index 50901cb..bd747bc 100644 --- a/src/sw/sw-footer.stories.tsx +++ b/src/sw/sw-footer.stories.tsx @@ -35,6 +35,7 @@ type Story = StoryObj function Screen({ width = 1920, canvas = 160, children }: { width?: number | string; canvas?: number; children: ReactNode }) { // 2026-10-22: always fluid (no inner scroller). `width` is a maximum, so the screen background and the footer share // the available width, commands that do not fit go to ▲ More, and an open menu is never clipped. + // 2026-11-13: in the narrowest 화면 너비 row, sw-stories.css keeps the row width and scrolls it in a narrower Story column. return (
@@ -78,14 +79,18 @@ function Workspace({ width = "100%" }: { width?: number | string }) { } export const FullFooter: Story = { - name: "SW 하단 명령어바", + name: "SW 명령어바", render: () => ( {/* 2026-10-22 (user request): no section heading; the live composition follows the page title. Behaviour: command click = select + history, category tabs, empty slot guide, aux toggles, ▲ = command window, Layout tabs, Enter runs. */} -
- + {/* 2026-11-13: below 760px (the narrowest 화면 너비 row) the composition keeps 760px and scrolls inside this section + (sw-stories.css .sw-story-fit) instead of widening the page; the footer's own minimum widths are unchanged. */} +
+
+ +
), @@ -386,12 +391,16 @@ export const Responsive: Story = { {/* Same 1920px row rule as SW Header / SW Viewer Responsive More (sw-stories.css .sw-story-responsive). */} -
+
{[1920, 1440, 1280, 950, 860, 760].map(width => ( // 2026-10-23: the 1920 row stays inside the Story column (as SW Viewer › Responsive) and is hidden on narrow pages. -
+ // 2026-11-13: rows wider than the Story column are hidden and the narrowest row (760) keeps its width and scrolls + // (sw-stories.css data-sw-width-row / data-sw-width-min), so every shown row is drawn at its title's width. +
- +
+ +
))} diff --git a/src/sw/sw-header.stories.tsx b/src/sw/sw-header.stories.tsx index cb346ce..19eddd0 100644 --- a/src/sw/sw-header.stories.tsx +++ b/src/sw/sw-header.stories.tsx @@ -790,18 +790,25 @@ export const ResponsiveMore: Story = {
- {MORE_WIDTHS.map(width => ( - // 2026-10-23: the 1920 row stays inside the Story column (as SW Viewer › Responsive) and is hidden on narrow pages. -
- - {/* Same structure as the Logo page (no black frame). This page only shows widths, so the five field dropdowns are - held closed with the public controlled props; the open callbacks and header data are unchanged. */} -
- -
-
-
- ))} + {MORE_WIDTHS.map(width => { + // 2026-11-13: rows wider than the Story column are hidden and the narrowest row keeps its width and scrolls + // (sw-stories.css data-sw-width-row / data-sw-width-min), so every shown row is drawn at its title's width. + const min = width === Math.min(...MORE_WIDTHS) + return ( + // 2026-10-23: the 1920 row stays inside the Story column (as SW Viewer › Responsive) and is hidden on narrow pages. +
+ + {/* Same structure as the Logo page (no black frame). This page only shows widths, so the five field dropdowns are + held closed with the public controlled props; the open callbacks and header data are unchanged. */} +
+
+ +
+
+
+
+ ) + })}
), @@ -934,8 +941,8 @@ function PropertyCompare() {
- {PROPERTY_STATES.map(state =>
-
{state.title}
+ {/* 2026-11-13: no visible caption — the panel title (선택된 객체 - …) already names the state; the figure keeps it as its accessible name. */} + {PROPERTY_STATES.map(state =>
setLog(lines => [...lines.slice(-7), `${state.example}: ${line}`])} />
)}
diff --git a/src/sw/sw-process-header.stories.tsx b/src/sw/sw-process-header.stories.tsx index 90f883b..e69d08f 100644 --- a/src/sw/sw-process-header.stories.tsx +++ b/src/sw/sw-process-header.stories.tsx @@ -170,19 +170,26 @@ export const Responsive: Story = {
- {WIDTHS.map(width => ( - // The 1920 row shows only on a page with 1920px of room, like the SW Header / Viewer Responsive pages. -
- - -
- - + {WIDTHS.map(width => { + // 2026-11-13: rows wider than the Story column are hidden and the narrowest row (1100) keeps its width and scrolls + // (sw-stories.css data-sw-width-row / data-sw-width-min), so every shown row is drawn at its title's width. + const min = width === Math.min(...WIDTHS) + return ( + // The 1920 row shows only on a page with 1920px of room, like the SW Header / Viewer Responsive pages. +
+ +
+ +
+ + +
+
- -
-
- ))} + +
+ ) + })}
), diff --git a/src/sw/sw-stories.css b/src/sw/sw-stories.css index 9ee384d..f1bd562 100644 --- a/src/sw/sw-stories.css +++ b/src/sw/sw-stories.css @@ -1,13 +1,47 @@ /* Story canvas only: frames that simulate a header width. Never styles the header itself. */ .sw-story-scroller { max-width: 100%; overflow-x: auto; } /* Responsive More pages: the 1920px row (sw-story-responsive-hidden) shows when the page has 1920px of usable width — - the page (container sw-story-page, not the browser) minus the Inner's 60px side margins (story-page.css, - 60px from a 768px viewport), so 2040px (2026-11-05; was 2000px with the former 40px gutters); with less room the whole row — + the page (container sw-story-page, not the browser) minus the Inner's 30px side margins (story-page.css, + 30px from a 768px viewport), so 1980px (2026-11-13; was 2040px with 60px margins, 2000px with the former 40px gutters); with less room the whole row — title, header, background, margin — is hidden. 2026-10-23: it stays inside the Story column like every other row (the former reach-out of the 80vw Inner, sw-story-responsive-wide, is gone); its preview is at most 1920px wide. */ -.sw-story-responsive { container-type: inline-size; } +.sw-story-responsive { container: sw-story-rows / inline-size; } .sb-page:has(.sw-story-responsive) { container: sw-story-page / inline-size; } -@container sw-story-page (max-width: 2039.98px) { .sw-story-responsive-hidden { display: none; } } +@container sw-story-page (max-width: 1979.98px) { .sw-story-responsive-hidden { display: none; } } +/* 2026-11-13 (user request): a width row is drawn at the width its title names. A row (data-sw-width-row) shows only where the + Story column (container sw-story-rows) is at least that wide; the page's narrowest row (data-sw-width-min) always shows and, + in a narrower column, keeps its own width (min-width on the frame inside .sw-story-fit__scroll) and scrolls there. + Each page keeps its own width set (the components' own breakpoints). min-width and overflow apply only below that width, so + open menus are not clipped where the row fits and a row can still be narrowed in place (e.g. the viewer More checks). */ +@container sw-story-rows (max-width: 1439.98px) { [data-sw-width-row="1440"]:not([data-sw-width-min]) { display: none; } } +@container sw-story-rows (max-width: 1365.98px) { [data-sw-width-row="1366"]:not([data-sw-width-min]) { display: none; } } +@container sw-story-rows (max-width: 1279.98px) { [data-sw-width-row="1280"]:not([data-sw-width-min]) { display: none; } } +@container sw-story-rows (max-width: 949.98px) { [data-sw-width-row="950"]:not([data-sw-width-min]) { display: none; } } +@container sw-story-rows (max-width: 859.98px) { [data-sw-width-row="860"]:not([data-sw-width-min]) { display: none; } } +@container sw-story-rows (max-width: 759.98px) { [data-sw-width-row="760"]:not([data-sw-width-min]) { display: none; } } +@container sw-story-rows (max-width: 1099.98px) { + [data-sw-width-min="1100"] .sw-story-fit__scroll { overflow-x: auto; } + [data-sw-width-min="1100"] .sw-story-fit__scroll > * { min-width: 1100px; } +} +@container sw-story-rows (max-width: 759.98px) { + [data-sw-width-min="760"] .sw-story-fit__scroll { overflow-x: auto; } + [data-sw-width-min="760"] .sw-story-fit__scroll > * { min-width: 760px; } +} +@container sw-story-rows (max-width: 519.98px) { + [data-sw-width-min="520"] .sw-story-fit__scroll { overflow-x: auto; } + [data-sw-width-min="520"] .sw-story-fit__scroll > * { min-width: 520px; } +} +/* The same rule for a single live example (SW 명령어바 760px, Viewer 로고 520px — the narrowest width each page set supports): + the example keeps that width and scrolls inside the Story column only when the column is narrower. */ +.sw-story-fit { container: sw-story-fit / inline-size; } +@container sw-story-fit (max-width: 759.98px) { + [data-sw-fit-min="760"] .sw-story-fit__scroll { overflow-x: auto; } + [data-sw-fit-min="760"] .sw-story-fit__scroll > * { min-width: 760px; } +} +@container sw-story-fit (max-width: 519.98px) { + [data-sw-fit-min="520"] .sw-story-fit__scroll { overflow-x: auto; } + [data-sw-fit-min="520"] .sw-story-fit__scroll > * { min-width: 520px; } +} .sw-story-frame { background: #060606; } /* Shared drawing-area canvas of the SW Stories (2026-10-19): the SW/Viewer black grid — 40px cells, 1px lines of HM green #266651 at 18%, on #0A0A0B. Used behind the SW Header work area and dropdown room and the viewer frames. */ @@ -32,6 +66,9 @@ story-page.css, so the 8px stack gap and each section's own top margin do not add up. Headers inside are unchanged. */ .sb-page .sw-story-stack:has(> .sb-page-section, > div > .sb-page-section) { gap: var(--sb-page-section-gap); margin-top: var(--sb-page-section-gap); } .sb-page .sw-story-stack > .sb-page-section, .sb-page .sw-story-stack > div > .sb-page-section { margin-top: 0; } +/* 2026-11-13: SW Viewer 화면 너비 has a hidden callback log (
)} diff --git a/src/sw/sw-viewer.stories.tsx b/src/sw/sw-viewer.stories.tsx index d48f5bd..96566cb 100644 --- a/src/sw/sw-viewer.stories.tsx +++ b/src/sw/sw-viewer.stories.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type ReactNode } from "react" +import { useLayoutEffect, useRef, useState, type ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" import { withSWNotice } from "./sw-story-notice" import { @@ -32,11 +32,12 @@ function Log({ lines }: { lines: string[] }) { } /** A viewer screen: header on top, the drawing area below it (panels are placed inside this frame). */ -function ViewerFrame({ width = 1440, height = 620, children, header }: { width?: number | null; height?: number; header: ReactNode; children?: ReactNode }) { +function ViewerFrame({ width = 1440, minWidth, height = 620, children, header }: { width?: number | null; minWidth?: number; height?: number; header: ReactNode; children?: ReactNode }) { return (
- {/* Fills the Story column up to `width` (null: the whole column), so panels placed at the right edge stay visible. */} -
+ {/* Fills the Story column up to `width` (null: the whole column), so panels placed at the right edge stay visible. + `minWidth` (설정창, 2026-11-13): the frame keeps that width and the scroller scrolls in a narrower column. */} +
{header}
{children}
@@ -83,9 +84,8 @@ export const ViewerHeader: Story = { {/* The minimize / close example was removed from this Story; the component keeps supporting it (SWHeaderWindowControls at the right end of `trailing`, VIEWER.md §2.1). */} - - - + {/* 2026-11-13: no "기본형" section title; the viewer is the page's first block. */} + ), } @@ -267,22 +267,57 @@ export const SettingsPanel: Story = { }, } +// 설정창 layout (2026-11-13): the three popups (레이어 314 / 속성 314 / 옵션 340px wide; 420 / 420 / 520px tall) sit in one +// row from a 1076px frame (the former fixed 24 / 368 / 712 placement). In a narrower frame SWViewerPanel kept them inside the +// frame on top of each other (68px overlap at a 1024px preview), so they move to 2 columns (레이어 · 속성, then 옵션; 속성 ends +// at 682px) and, below a 700px frame, to 1 column, and the frame grows to fit. The frame keeps at least 520px (the narrowest +// viewer width) and scrolls in a narrower column, so the 340px 옵션 popup always fits (SWViewerPanel width unchanged). Storybook presentation only: the panels keep their public API; each layout +// remounts them (key) because defaultPosition is read on mount. The Story column width is measured (useLayoutEffect). +type PanelLayout = 3 | 2 | 1 +type PanelPoint = { left: number; top: number } +const PANEL_LAYOUTS: Record = { + 3: { height: 620, layers: { left: 24, top: 16 }, properties: { left: 368, top: 16 }, options: { left: 712, top: 16 } }, + 2: { height: 1052, layers: { left: 24, top: 16 }, properties: { left: 368, top: 16 }, options: { left: 24, top: 452 } }, + 1: { height: 1488, layers: { left: 16, top: 16 }, properties: { left: 16, top: 452 }, options: { left: 16, top: 888 } }, +} +function usePanelLayout() { + const ref = useRef(null) + const [layout, setLayout] = useState(3) + useLayoutEffect(() => { + const node = ref.current + if (!node) return + const update = () => { + const width = Math.min(node.clientWidth, 1440) + setLayout(width >= 1076 ? 3 : width >= 700 ? 2 : 1) + } + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + return { ref, layout } +} + export const ContentVariants: Story = { // 2026-10-21: the one settings-popup Story (former 설정창 and 같은 프레임, 다른 내부 콘텐츠 pages); ID kept. // 2026-10-22 (user request): the 축척 지정 demo popup was removed. name: "설정창", render: () => { const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["layers", "properties", "options"] }) + const { ref, layout } = usePanelLayout() + const place = PANEL_LAYOUTS[layout] return ( - {/* 2026-10-22: 620px fits the tallest popup (옵션, 520px) now that 축척 지정 is gone. */} - }> - - - - + {/* 2026-10-22: 620px fits the tallest popup (옵션, 520px) in the one-row layout now that 축척 지정 is gone. */} +
+ }> + + + + +
) }, @@ -328,12 +363,16 @@ export const Logo: Story = { return ( -
+ {/* 2026-11-13: below the narrowest viewer width (520px, the 화면 너비 page's last row) the header keeps 520px and + scrolls inside its section (sw-stories.css .sw-story-fit) instead of widening the page. */} +
{rows.map(row => ( - {/* 1440px is a maximum (2026-10-22): no scroller; the header fits the available width. */} -
- + {/* 1440px is a maximum (2026-10-22): the header fits the available width down to 520px. */} +
+
+ +
))} @@ -357,10 +396,13 @@ export const Responsive: Story = { // 2026-10-22 (user request): the 1920 row no longer reaches out of the content area (no sw-story-responsive-wide); // like the other rows it is a maximum inside the Story column. 2026-10-23: it is hidden on the same narrow pages // as the SW Header / Footer 1920 rows (sw-story-responsive-hidden, sw-stories.css), e.g. a 1920px screen. -
- {/* The row width is a maximum (2026-10-22): on a narrower screen the header shrinks with it instead of scrolling. */} - {/* Same structure as the Logo page (no scroller / bottom padding), so the spacing matches it. */} -
+ // 2026-11-13: rows wider than the Story column are hidden and the narrowest row (520) keeps its width and scrolls + // (sw-stories.css data-sw-width-row / data-sw-width-min), so every shown row is drawn at its title's width. +
+ {/* Same structure as the Logo page (no black frame / bottom padding), so the spacing matches it. */} +
+
+
))}
diff --git a/src/theme/verification-baseline.json b/src/theme/verification-baseline.json index 2bcc9cd..db76d58 100644 --- a/src/theme/verification-baseline.json +++ b/src/theme/verification-baseline.json @@ -610,7 +610,7 @@ "--hm-lnb-brand-text-gap": "5px", "--hm-lnb-item-min-height": "48px", "--hm-lnb-item-padding-block": "10px", - "--hm-lnb-item-padding-start": "18px", + "--hm-lnb-item-padding-start": "12px", "--hm-lnb-item-gap": "12px", "--hm-lnb-item-font-size": "14px", "--hm-lnb-item-line-height": "28px",