Author SHA1 Message Date
shkim4 e42b67c53a feat(ui): standardize LNB sizing and active depth effect
- Adopt the official LNB desktop spec as package defaults: 240px column,
  14px menu text, 48px 1-depth rows and 42px 2/3-depth rows, with new
  item/subitem padding-block and profile min-height tokens.
- Match --hm-lnb-current-depth to the Storybook Manager selected item:
  sampled soft inset falloff (left/top/bottom) and a fade over the last
  quarter, keeping the concave corners and content join.
- Drop the size overrides from the LNB Story CSS and update the LNB, GNB
  and Dashboard docs, AI guides and the theme verification baseline.
2026-11-09 21:34:28 +09:00
shkim4 89f265ae2f chore(storybook): rename the route map title menu to 사업명 2026-11-09 20:06:42 +09:00
shkim4 237866385a chore(storybook): localize sidebar menus and hide unused stories 2026-11-09 19:58:38 +09:00
shkim4 2e203627c5 fix(storybook): type the font path plugin as a Vite Plugin
this.warn() in the hm-ui-theme-font-path transform failed tsc -b with
TS2339 because the untyped object literal made `this` the literal itself.
Annotate it as Vite's Plugin so `this` is the plugin context. Font URL
replacement and the warning behavior are unchanged.
2026-11-09 19:35:35 +09:00
shkim4 074ed8706b docs(theme): align palette references with the updated Green and Brown steps
Replace the old Green Default #123328 and Dark steps in the component docs,
brand color steps, runtime theme contract, token guide, loading screen doc and
the @hm-design/ui AI design rules. check:ai-docs passes. Remaining references
elsewhere are left for the final documentation consistency pass.
2026-11-09 19:23:54 +09:00
shkim4 73ef042c55 feat(theme): update Hanmac Green and Brown palette steps
Seven shared Hanmac color tokens change:
- Green Default #123328 -> #164434, Dark 1 #0a221d -> #11372d,
  Dark 2 #071e18 -> #0b2b23, Dark 3 #020a07 -> #030e0a
- Brown Default #3e3523 -> #4e422d, Dark 1 #352d1d -> #403726,
  Dark 3 #1d1810 -> #0d0b07

Updates primitives.css, the generated default values, themes/default.json,
the verification baseline and the hard-coded Default expectations in the
theme Stories and the quality-reference script. --hm-sem-on-accent is
intentionally unchanged. check:theme-defaults reports generated outputs match.
2026-11-09 19:23:43 +09:00
shkim4 be0661062f fix(storybook): load local Noto Sans KR and this checkout's UI theme
- Dev server: rewrite the @hm-design/ui dist font URL to the /fonts static
  directory so Preview no longer gets a 403 from the Vite fs allow list.
- When node_modules/@hm-design/ui is linked to another checkout, resolve the
  five @hm-design/ui entry points to this checkout's packages/ui source so the
  current tokens are shown instead of a stale palette. Checkouts whose links
  already point at their own packages/ui keep the dist-based resolution.
2026-11-09 19:23:29 +09:00
shkim4 ea984ab3a6 chore(storybook): refine navigation and foundation labels
- Rename Foundation/Typography to 폰트 and SW/Footer to SW/하단 명령어바 in
  the sidebar order; Story IDs are kept (foundation-typography, sw-footer).
- Hanmac Color: label Light 4 as "보더 하이라이트용" and add a divider between
  Light 4 and Light 3.
- Hide Patterns/Layout/Page Header from the sidebar with the verification tag
  (Story IDs and direct URLs are unchanged).
- Remove the Variants section from the representative LNB page; With Logo and
  Long Labels remain as verification Stories.
2026-11-09 19:23:23 +09:00
shkim4 9394050c72 feat(storybook): add shared SW notice and refine SW previews
- Add StoryNotice and a withSWNotice decorator so every visible SW Story
  shows the common notice above its title; non-SW Stories are unaffected.
- Align the SW Header overview with the common SW Story page layout, let the
  preview Stage fill the available width and keep inner scroll on narrow screens.
- Rename SW/Footer to SW/하단 명령어바 (sidebar title only, Story IDs fixed via
  id: "sw-footer"), drop the top SWHeader from its preview and lower the
  workspace height bounds by the removed 49px.
2026-11-09 19:23:17 +09:00
shkim4 a460426535 style(storybook): tidy SW header, step header and viewer stories
- Responsive More / Logo / Settings Popup / Property Panel names in Korean (IDs unchanged)
- SW Step Header representative page keeps one Multi Step example
- Viewer Header drops the minimize/close example (component support unchanged)
- Header Responsive page uses the Logo page structure
2026-11-09 18:26:12 +09:00
shkim4 0f1574f80f style(storybook): localize 조직도 and 노선도 menus, Department Colors 3 columns
- OrgChart -> 조직도 and sub menus (개요, 부서별 색상, 전체 구성, 확장 조직도); Story IDs kept
- 노선도 menu/page titles in Korean (제목, 정보, 노선·공간, 시설물)
- Department Colors cards 3 columns (2 under 960px)
2026-11-09 18:26:11 +09:00
shkim4 031f88033d style(storybook): localize Foundation menu and reorder sidebar
- brand title to 한맥 디자인 시스템
- Foundation: 한맥 컬러 / 가족사 컬러 titles, Story IDs kept via explicit id
- Company Colors card order 2/2/3, Foundation/Layout hidden with verification tag
- sidebar order for Foundation and 조직도
2026-11-09 18:26:09 +09:00
shkim4 d0f4ec6ed4 chore(storybook): reorder SW and rename Center Graphic 2026-11-09 17:05:53 +09:00
shkim4 b33ca2a157 style(storybook): refine patterns data and layout stories 2026-11-09 16:42:47 +09:00
shkim4 3bebe8a660 style(storybook): refine overlay story layout 2026-11-09 16:15:23 +09:00
shkim4 bef164489b style(storybook): refine data display story layout 2026-11-09 16:15:22 +09:00
shkim4 3868ca09c7 style(storybook): refine feedback story layout 2026-11-09 16:09:01 +09:00
shkim4 cd60ab661a style(storybook): refine disclosure story layout 2026-11-09 16:09:01 +09:00
shkim4 0ea5b46f74 style(storybook): refine search and date story layout 2026-11-09 16:09:01 +09:00
shkim4 8827eb2681 style(storybook): refine foundation and navigation stories 2026-11-09 15:48:34 +09:00
shkim4 f6b92d781a style(storybook): refine form component stories 2026-11-09 15:33:23 +09:00
shkim4 e47eb040f2 feat(storybook): refine manager layout and Hanmac branding 2026-11-09 13:59:46 +09:00
shkim4 fc0a3e7725 feat(storybook): refine Manager LNB, HM Color and Patterns/Layout/LNB
- Manager LNB: 246px width, 16px brand title, 14px/700 menu and section
  titles, depth1 48px / depth2+ 42px rows, 24px section gap
- Foundation: rename Color to HM Color (Story ID foundation-color--all kept),
  remove Company Colors subtitle and the page-title bottom border
- Patterns/Layout/LNB: Storybook-only overrides in lnb.stories.css
  (240px column, 212px min width, 14px menu, 48/42px rows, profile text,
  group dividers); representative board dividers per section
- Sync story inventory and docs (brand-color-steps, lnb); shared package
  defaults and tokens are unchanged
2026-11-09 12:01:03 +09:00
132 changed files with 2158 additions and 881 deletions

No files matched your search

+61 -1
View File
@@ -1,5 +1,58 @@
import type { StorybookConfig } from "@storybook/react-vite" import type { StorybookConfig } from "@storybook/react-vite"
import { readFileSync } from "node:fs" import { readFileSync, realpathSync } from "node:fs"
import { fileURLToPath } from "node:url"
import path from "node:path"
import type { Plugin } from "vite"
// Checkouts that share another checkout's node_modules (a linked git worktree whose node_modules is a junction) also
// inherit that checkout's node_modules/@hm-design/* links. Those links point at the other checkout's packages/ui and its
// compiled dist, so Storybook would show the other checkout's tokens (a stale palette) instead of this checkout's.
// Only in that case, resolve the five @hm-design/ui entry points to this checkout's own source. Nothing is built, the
// other checkout's node_modules and dist are only read, and a checkout whose links already point at its own
// packages/ui keeps the unchanged dist-based resolution.
const checkoutRoot = fileURLToPath(new URL("../", import.meta.url))
const localUiRoot = path.join(checkoutRoot, "packages/ui")
function uiLinkedElsewhere() {
try {
return realpathSync(path.join(checkoutRoot, "node_modules/@hm-design/ui")) !== realpathSync(localUiRoot)
} catch {
return false
}
}
const localUiAliases = [
[/^@hm-design\/ui$/, "src/index.ts"],
[/^@hm-design\/ui\/theme$/, "src/theme/index.ts"],
[/^@hm-design\/ui\/theme\.css$/, "src/theme.css"],
[/^@hm-design\/ui\/tailwind\.css$/, "src/tailwind.css"],
[/^@hm-design\/ui\/dashboard-status\.css$/, "src/patterns/dashboard-status/dashboard-status.css"],
].map(([find, file]) => ({ find: find as RegExp, replacement: path.join(localUiRoot, file as string) }))
// @hm-design/ui's dist/theme.css declares the bundled font with a relative URL. When node_modules/@hm-design/ui is
// symlinked to a checkout outside this Vite root, Vite rewrites that URL to /@fs/<outside path> and the dev preview
// gets a 403 ("outside of Vite serving allow list"). Point only that font URL at the /fonts static directory the
// Manager already uses, instead of widening server.fs.allow. theme.css is inlined through @import, so the rewrite
// runs on the already-resolved CSS and is limited to the exact ".../ui/dist/fonts/NotoSansKR-variable.ttf" URL.
// Dev server only: the static build emits the font as a bundled asset.
const UI_DIST_FONT_URL = /[^"'()\\\s]*\/ui\/dist\/fonts\/NotoSansKR-variable\.ttf/g
const FONT_STATIC_URL = "/fonts/NotoSansKR-variable.ttf"
const hmUiThemeFontPath: Plugin = {
name: "hm-ui-theme-font-path",
apply: "serve" as const,
enforce: "post" as const,
transform(code: string, id: string) {
const file = id.split("?")[0]
if (!file.endsWith(".css") || !code.includes("NotoSansKR-variable.ttf")) return null
const next = code.replace(UI_DIST_FONT_URL, FONT_STATIC_URL)
if (next === code) {
if (!code.includes(FONT_STATIC_URL)) {
this.warn(`[hm-ui-theme-font-path] ${file}: found NotoSansKR-variable.ttf but not at ".../ui/dist/fonts/"; the URL was left unchanged and Preview may fail to load Noto Sans KR.`)
}
return null
}
return { code: next, map: null }
},
}
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(ts|tsx)"], stories: ["../src/**/*.stories.@(ts|tsx)"],
@@ -21,6 +74,13 @@ const config: StorybookConfig = {
viteFinal: (config) => { viteFinal: (config) => {
config.server ??= {} config.server ??= {}
config.server.watch = { ...config.server.watch, ignored: ["**/_local/**", "**/storybook-static/**"] } config.server.watch = { ...config.server.watch, ignored: ["**/_local/**", "**/storybook-static/**"] }
config.plugins = [...(config.plugins ?? []), hmUiThemeFontPath]
if (uiLinkedElsewhere()) {
config.resolve ??= {}
const existing = config.resolve.alias
const current = Array.isArray(existing) ? existing : Object.entries(existing ?? {}).map(([find, replacement]) => ({ find, replacement }))
config.resolve.alias = [...localUiAliases, ...current]
}
return config return config
}, },
framework: "@storybook/react-vite", framework: "@storybook/react-vite",
+33 -14
View File
@@ -61,13 +61,15 @@
background: var(--hm-fg-on-green-secondary); background: var(--hm-fg-on-green-secondary);
} }
#storybook-sidebar-region .sidebar-header { #storybook-sidebar-region .sidebar-header {
margin: 0 20px 24px 0; margin: 0 20px 0px 0;
padding: 0; padding: 0;
color: var(--color-fg-on-green-primary); color: var(--color-fg-on-green-primary);
} }
/* The Settings button is hidden, so the title uses the full header width. */
#storybook-sidebar-region .sidebar-header > div { margin-right: 0; }
#storybook-sidebar-region .sidebar-header > div a { #storybook-sidebar-region .sidebar-header > div a {
color: var(--color-fg-on-green-primary); color: var(--color-fg-on-green-primary);
font: 700 20px/30px var(--hm-manager-font); font: 700 16px/30px var(--hm-manager-font);
white-space: normal; white-space: normal;
/* §6 Text Border: brand title keeps the original 1px (no shadow). */ /* §6 Text Border: brand title keeps the original 1px (no shadow). */
-webkit-text-stroke: 1px var(--effect-text-border-color); -webkit-text-stroke: 1px var(--effect-text-border-color);
@@ -78,7 +80,6 @@
} }
#storybook-sidebar-region [role='search'] { #storybook-sidebar-region [role='search'] {
margin-right: 20px; margin-right: 20px;
margin-bottom: 24px;
} }
#storybook-sidebar-region .search-field { #storybook-sidebar-region .search-field {
min-height: 44px; min-height: 44px;
@@ -104,21 +105,25 @@
} }
/* Storybook's named creation action only; search/filter remain available. */ /* Storybook's named creation action only; search/filter remain available. */
#storybook-sidebar-region button[aria-label='Create a new story'] { display: none; } #storybook-sidebar-region button[aria-label='Create a new story'] { display: none; }
/* Storybook Settings menu trigger only; sidebar search, tag filter and expand/collapse remain. */
#storybook-sidebar-region .sidebar-header button[aria-label='Settings'] { display: none; }
/* Storybook sidebar search landmark (input, icon, shortcut hint, tag filter); the tree and header remain. */
#storybook-sidebar-region [role='search'] { display: none; }
#storybook-explorer-tree .sidebar-subheading { #storybook-explorer-tree .sidebar-subheading {
position: relative; position: relative;
margin: 0; margin: 0;
padding: 0 20px 0 0; padding: 0 20px 0 0;
min-height: 56px; min-height: 48px;
color: var(--hm-fg-on-green-secondary); color: var(--hm-fg-on-green-secondary);
} }
#storybook-explorer-tree .sidebar-subheading[data-hm-section]:not([data-hm-section='0']) { #storybook-explorer-tree .sidebar-subheading[data-hm-section]:not([data-hm-section='0']) {
margin-top: 40px; margin-top: 24px;
} }
#storybook-explorer-tree .sidebar-subheading[data-hm-section]:not([data-hm-section='0'])::before { #storybook-explorer-tree .sidebar-subheading[data-hm-section]:not([data-hm-section='0'])::before {
content: ''; content: '';
position: absolute; position: absolute;
top: -20px; top: -12px;
left: 0; left: 0;
right: 20px; right: 20px;
height: 2px; height: 2px;
@@ -126,9 +131,9 @@
mask-image: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent); mask-image: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent);
} }
#storybook-explorer-tree .sidebar-subheading > button { #storybook-explorer-tree .sidebar-subheading > button {
min-height: 56px; min-height: 48px;
color: inherit; color: inherit;
font: 700 16px/28px var(--hm-manager-font); font: 700 14px/28px var(--hm-manager-font);
letter-spacing: 0; letter-spacing: 0;
text-transform: none; text-transform: none;
} }
@@ -136,7 +141,7 @@
position: relative; position: relative;
margin: 0; margin: 0;
padding: 0; padding: 0;
min-height: 56px; min-height: 48px;
overflow: visible; overflow: visible;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
@@ -163,22 +168,30 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
min-height: 56px; min-height: 48px;
height: auto; height: auto;
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 14px 32px 14px 18px; padding: 10px 32px 10px 18px;
border: 0; border: 0;
border-radius: 4px 0 0 4px; border-radius: 4px 0 0 4px;
background: transparent; background: transparent;
color: inherit; color: inherit;
font: 700 18px/28px var(--hm-manager-font); font: 700 14px/28px var(--hm-manager-font);
text-align: left; text-align: left;
text-decoration: none; text-decoration: none;
white-space: normal; white-space: normal;
overflow: visible; overflow: visible;
text-overflow: clip; text-overflow: clip;
} }
/* Sub rows (depth 2+): 42px (7px + 28px line + 7px), shorter than the 48px top-level rows. */
#storybook-explorer-tree .sidebar-item:is([data-hm-depth='2'], [data-hm-depth='3'], [data-hm-depth='4']) {
min-height: 42px;
}
#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;
}
#storybook-explorer-tree .sidebar-item[data-hm-depth='2'] > :is(a[id], button[id]:not([data-testid])) { #storybook-explorer-tree .sidebar-item[data-hm-depth='2'] > :is(a[id], button[id]:not([data-testid])) {
margin-left: 32px; margin-left: 32px;
width: calc(100% - 32px); width: calc(100% - 32px);
@@ -188,6 +201,10 @@
margin-left: 44px; margin-left: 44px;
width: calc(100% - 44px); width: calc(100% - 44px);
} }
/* Menu item labels and section heading labels (.sidebar-subheading) are both 14px / 700. */
#storybook-explorer-tree .sidebar-item .hm-manager-label {
font-size: 14px;
}
#storybook-explorer-tree .hm-manager-label { #storybook-explorer-tree .hm-manager-label {
min-width: 0; min-width: 0;
white-space: normal; white-space: normal;
@@ -198,8 +215,10 @@
} }
/* D-093 D-014 (§6 Text Border): non-selected labels, including hover and the /* D-093 D-014 (§6 Text Border): non-selected labels, including hover and the
yellow current parent, use the specimen's 1.5px border plus lower shadow. yellow current parent, use the specimen's 1.5px border plus lower shadow.
Thickness and offset are REVIEW VALUEs. Selected labels carry no effect. */ Thickness and offset are REVIEW VALUEs. Selected labels carry no effect.
#storybook-explorer-tree .sidebar-item[data-selected='false'] .hm-manager-label { Section heading labels (Foundation, Components, ...) share this exact rule. */
#storybook-explorer-tree .sidebar-item[data-selected='false'] .hm-manager-label,
#storybook-explorer-tree .sidebar-subheading .hm-manager-label {
-webkit-text-stroke: 1.5px var(--effect-text-border-color); -webkit-text-stroke: 1.5px var(--effect-text-border-color);
paint-order: stroke fill; paint-order: stroke fill;
text-shadow: 1px 2px 1px var(--effect-text-border-color); text-shadow: 1px 2px 1px var(--effect-text-border-color);
+89 -2
View File
@@ -28,13 +28,100 @@ toolbarStyle.textContent = `
` `
document.head.appendChild(toolbarStyle) document.head.appendChild(toolbarStyle)
// Sidebar display names only. Story titles, Story IDs and storySort stay English; items not listed keep item.name.
const koreanLabels = {
foundation: '기본 요소',
components: '컴포넌트',
'components-form': '폼 요소',
'components-navigation': '내비게이션',
'components-overlay': '오버레이',
'components-data-display': '데이터 표시',
'components-disclosure': '영역 확장·조절',
'components-search-date': '검색·날짜',
'components-feedback': '알림·피드백',
'components-form-button': '버튼',
'components-form-input': '입력창',
'components-form-textarea': '여러 줄 입력창',
'components-form-select': '선택 목록',
'components-form-checkbox': '체크박스',
'components-form-radio-group': '라디오 버튼 그룹',
'components-form-switch': '스위치',
'components-form-slider': '슬라이더',
'components-form-toggle': '토글 버튼',
'components-navigation-tabs': '탭',
'components-navigation-breadcrumb': '이동 경로',
'components-navigation-pagination': '페이지네이션',
'components-navigation-navigation-menu': '내비게이션 메뉴',
'components-navigation-menubar': '메뉴바',
'components-overlay-dialog': '대화상자',
'components-overlay-alert-dialog': '확인·경고 대화상자',
'components-overlay-sheet': '시트 패널',
'components-overlay-drawer': '드로어',
'components-overlay-dropdown-menu': '드롭다운 메뉴',
'components-overlay-context-menu': '컨텍스트 메뉴',
'components-overlay-popover': '팝오버',
'components-overlay-tooltip': '툴팁',
'components-overlay-hover-card': '호버 카드',
'components-data-display-card': '카드',
'components-data-display-table': '테이블',
'components-data-display-badge': '배지',
'components-data-display-avatar': '아바타',
'components-data-display-progress': '진행률',
'components-data-display-skeleton': '스켈레톤',
'components-data-display-separator': '구분선',
'components-data-display-aspect-ratio': '화면 비율',
'components-data-display-carousel': '캐러셀',
'components-data-display-scroll-area': '스크롤 영역',
'components-disclosure-accordion': '아코디언',
'components-disclosure-collapsible': '접기·펼치기',
'components-disclosure-resizable': '크기 조절',
'components-search-date-command': '명령어 검색',
'components-search-date-combobox': '콤보박스',
'components-search-date-calendar': '달력',
'components-search-date-date-picker': '날짜 선택기',
'components-feedback-alert': '알림 메시지',
'components-feedback-sonner': '토스트 알림',
patterns: 'UI 패턴',
'patterns-data': '데이터 조회',
'patterns-data-data-table': '데이터 테이블',
'patterns-data-filter-bar': '필터 바',
'patterns-layout': '화면 구성',
'patterns-layout-gnb': '전체 메뉴 (GNB)',
'patterns-layout-lnb': '좌측 메뉴 (LNB)',
'patterns-layout-dashboard-layout': '대시보드',
'patterns-layout-page-header': '페이지 헤더',
sw: 'SW UI',
'sw-header': '헤더',
'sw-header--overview': 'SW 헤더',
'sw-header--responsive-more': '화면 너비 대응',
'sw-viewer--responsive': '화면 너비 대응',
'sw-step-header--responsive': '화면 너비 대응',
'sw-footer--responsive': '화면 너비 대응',
'sw-viewer': '뷰어',
'sw-viewer--viewer-header': '뷰어 헤더',
'sw-step-header': '단계별',
'sw-step-header--process-header': '단계별 헤더',
'sw-popup': '팝업',
}
// A component with one visible Story is shown as that Story (`<component-id>--all`) under the component's name.
// A real Story named "All" under an expanded component keeps its own name.
const koreanLabel = (item) => {
const hoisted = item.type === 'story' && item.name !== 'All' ? koreanLabels[item.id.replace(/--all$/, '')] : undefined
return koreanLabels[item.id] ?? hoisted ?? item.name
}
addons.setConfig({ addons.setConfig({
layout: { layout: {
showPanel: false, showPanel: false,
showToolbar: false,
// LNB width 246px. recentVisibleSizes keeps the same width when the sidebar is collapsed and expanded again.
navSize: 246,
recentVisibleSizes: { navSize: 246 },
}, },
theme: create({ theme: create({
base: 'light', base: 'light',
brandTitle: 'HM Design System', brandTitle: '한맥 디자인 시스템',
brandUrl: './', brandUrl: './',
brandTarget: '_self', brandTarget: '_self',
fontBase: "'Hanmac Manager Noto Sans KR', 'Noto Sans KR', 'Malgun Gothic', sans-serif", fontBase: "'Hanmac Manager Noto Sans KR', 'Noto Sans KR', 'Malgun Gothic', sans-serif",
@@ -48,7 +135,7 @@ addons.setConfig({
barSelectedColor: '#123328', barSelectedColor: '#123328',
}), }),
sidebar: { sidebar: {
renderLabel: (item) => createElement('span', { className: 'hm-manager-label' }, item.name), renderLabel: (item) => createElement('span', { className: 'hm-manager-label' }, koreanLabel(item)),
}, },
}) })
+5 -5
View File
@@ -25,7 +25,7 @@ const preview: Preview = {
includeNames: true, includeNames: true,
order: [ order: [
"Foundation", "Foundation",
["Color", "Company Colors", "Typography", "Layout"], ["한맥 컬러", "가족사 컬러", "폰트"],
"Components", "Components",
[ [
"Form", "Form",
@@ -45,11 +45,11 @@ const preview: Preview = {
], ],
"Patterns", "Patterns",
["Data", ["Data Table", "Filter Bar"], "Layout", ["GNB", "LNB", "Dashboard Layout", "Loading Screen"]], ["Data", ["Data Table", "Filter Bar"], "Layout", ["GNB", "LNB", "Dashboard Layout", "Loading Screen"]],
"OrgChart",
["Overview", "Department Colors", "Full Composition", "Expanded OrgChart"],
"Center Graphic",
"SW", "SW",
["Header", ["SW Header", "*"], "Footer", ["SW Footer", "*"], "Popup", "Viewer", "Theme"], ["Header", ["SW Header", "*"], "Viewer", "Step Header", "Popup", "하단 명령어바", ["SW 하단 명령어바", "*"], "Theme"],
"조직도",
["개요", "부서별 색상", "전체 구성", "확장 조직도"],
"노선도",
"*", "*",
// With includeNames, keeps the Loader group after the Runtime Contract Stories (its place without names). // With includeNames, keeps the Loader group after the Runtime Contract Stories (its place without names).
"Theme", "Theme",
+9 -5
View File
@@ -7,15 +7,15 @@ This is a classification record. No Story IDs, exports or titles are renamed. HI
## Summary ## Summary
- PRIMARY: 82 - PRIMARY: 82
- VERIFICATION: 252 - VERIFICATION: 256
- UTILITY: 38 - UTILITY: 38
- LEGACY / DUPLICATE: 0 - LEGACY / DUPLICATE: 0
- KEEP: 82 - KEEP: 82
- HIDE: 290 - HIDE: 294
- MERGE_CANDIDATE: 33 - MERGE_CANDIDATE: 33
- DELETE_CANDIDATE: 0 - DELETE_CANDIDATE: 0
- Story files: 82 - Story files: 82
- Stories: 372 - Stories: 376
- Hierarchy: Foundation → Components → Patterns → Center Graphic → Theme - Hierarchy: Foundation → Components → Patterns → Center Graphic → Theme
## Full inventory ## Full inventory
@@ -171,6 +171,7 @@ Source: `src/components/checkbox.stories.tsx`; meta.tags: []
- Indeterminate — Indeterminate; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Indeterminate — Indeterminate; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed. - Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
- Error — Error; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Error — Error; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- LongLabel — Long Label; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Playground — Playground; tags: ["verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Playground — Playground; tags: ["verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
### Components/Disclosure/Collapsible ### Components/Disclosure/Collapsible
@@ -467,6 +468,8 @@ Source: `src/components/textarea.stories.tsx`; meta.tags: []
- InvalidFocus — Invalid Focus; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - InvalidFocus — Invalid Focus; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- ErrorMessage — Error Message; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - ErrorMessage — Error Message; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Required — Required; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Required — Required; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Multiline — Multiline · Wrap; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- ResizeNone — Resize · none; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Readonly — Readonly; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Readonly — Readonly; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed. - Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
- LongContent — Long Content; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - LongContent — Long Content; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
@@ -480,6 +483,7 @@ Source: `src/components/toggle-group.stories.tsx`; meta.tags: []
- SingleSelection — Single; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - SingleSelection — Single; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- MultipleSelection — Multiple; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - MultipleSelection — Multiple; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- VerticalGroup — Vertical; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - VerticalGroup — Vertical; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- ManyOptions — Many Options; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
- ContentNodes — Content Nodes; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - ContentNodes — Content Nodes; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
### Components/Form/Toggle ### Components/Form/Toggle
@@ -499,11 +503,11 @@ Source: `src/components/tooltip.stories.tsx`; meta.tags: []
- All — Tooltip; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. - All — Tooltip; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- Open — Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar. - Open — Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
### Foundation/Color ### Foundation/Hanmac Color
Source: `src/foundation/color.stories.tsx`; meta.tags: [] Source: `src/foundation/color.stories.tsx`; meta.tags: []
- All — Color; tags: []; PRIMARY; KEEP. Representative story or distinct reference page. - All — Hanmac Color; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
### Foundation/Company Colors ### Foundation/Company Colors
+65 -5
View File
@@ -1,16 +1,16 @@
{ {
"summary": { "summary": {
"storyFiles": 82, "storyFiles": 82,
"stories": 372, "stories": 376,
"categories": { "categories": {
"PRIMARY": 82, "PRIMARY": 82,
"VERIFICATION": 252, "VERIFICATION": 256,
"UTILITY": 38, "UTILITY": 38,
"LEGACY / DUPLICATE": 0 "LEGACY / DUPLICATE": 0
}, },
"decisions": { "decisions": {
"KEEP": 82, "KEEP": 82,
"HIDE": 290, "HIDE": 294,
"MERGE_CANDIDATE": 33, "MERGE_CANDIDATE": 33,
"DELETE_CANDIDATE": 0 "DELETE_CANDIDATE": 0
}, },
@@ -1061,6 +1061,21 @@
"candidate": null, "candidate": null,
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar." "reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
}, },
{
"id": "components-form-checkbox--long-label",
"exportName": "LongLabel",
"name": "Long Label",
"storyTags": [
"verification"
],
"effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{ {
"id": "components-form-checkbox--playground", "id": "components-form-checkbox--playground",
"exportName": "Playground", "exportName": "Playground",
@@ -3384,6 +3399,36 @@
"candidate": null, "candidate": null,
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar." "reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
}, },
{
"id": "components-form-textarea--multiline",
"exportName": "Multiline",
"name": "Multiline · Wrap",
"storyTags": [
"verification"
],
"effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{
"id": "components-form-textarea--resize-none",
"exportName": "ResizeNone",
"name": "Resize · none",
"storyTags": [
"verification"
],
"effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{ {
"id": "components-form-textarea--readonly", "id": "components-form-textarea--readonly",
"exportName": "Readonly", "exportName": "Readonly",
@@ -3507,6 +3552,21 @@
"candidate": null, "candidate": null,
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar." "reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
}, },
{
"id": "components-form-toggle-group--many-options",
"exportName": "ManyOptions",
"name": "Many Options",
"storyTags": [
"verification"
],
"effectiveTags": [
"verification"
],
"category": "VERIFICATION",
"decision": "HIDE",
"candidate": null,
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
},
{ {
"id": "components-form-toggle-group--content-nodes", "id": "components-form-toggle-group--content-nodes",
"exportName": "ContentNodes", "exportName": "ContentNodes",
@@ -3637,13 +3697,13 @@
}, },
{ {
"file": "src/foundation/color.stories.tsx", "file": "src/foundation/color.stories.tsx",
"title": "Foundation/Color", "title": "Foundation/Hanmac Color",
"metaTags": [], "metaTags": [],
"stories": [ "stories": [
{ {
"id": "foundation-color--all", "id": "foundation-color--all",
"exportName": "All", "exportName": "All",
"name": "Color", "name": "Hanmac Color",
"storyTags": [], "storyTags": [],
"effectiveTags": [], "effectiveTags": [],
"category": "PRIMARY", "category": "PRIMARY",
+1 -1
View File
@@ -32,7 +32,7 @@
| variant | 면 | 글자 | 테두리 | | variant | 면 | 글자 | 테두리 |
|---|---|---|---| |---|---|---|---|
| `default` | `--hm-sem-action-primary` (Primary Green `#123328`) | `--hm-sem-on-action-primary` (White) | 면과 동일 | | `default` | `--hm-sem-action-primary` (Primary Green `#164434`) | `--hm-sem-on-action-primary` (White) | 면과 동일 |
| `secondary` | `--hm-sem-bg-subtle` | `--hm-sem-fg-secondary` | 면과 동일 | | `secondary` | `--hm-sem-bg-subtle` | `--hm-sem-fg-secondary` | 면과 동일 |
| `destructive` | `--hm-sem-action-destructive` | White | 면과 동일 | | `destructive` | `--hm-sem-action-destructive` | White | 면과 동일 |
| `outline` | `--hm-sem-bg-surface` | `--hm-sem-fg-primary` | `--hm-sem-stroke-default` | | `outline` | `--hm-sem-bg-surface` | `--hm-sem-fg-primary` | `--hm-sem-stroke-default` |
+1 -1
View File
@@ -69,7 +69,7 @@ Component Mapping 위치: `packages/ui/src/theme/breadcrumb.css` (`--hm-breadcru
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Breadcrumb"): Basic(3 depth), Ellipsis(Dropdown Menu 조합), Long path, Custom link(asChild)와 custom separator를 한 페이지에 둔다. - 대표 Story `All`("Breadcrumb"): Variants(Basic(3 depth), Ellipsis(Dropdown Menu 조합), Custom link(asChild)와 custom separator)를 PC 균등 3열로, Usage(Long path, 최대 900px)를 한 페이지에 둔다(2026-11-09 Overview 제거).
- verification: `Basic`, `Ellipsis`, `Long Path`. - verification: `Basic`, `Ellipsis`, `Long Path`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1 -1
View File
@@ -150,7 +150,7 @@ CVA 구조, `data-slot`, `asChild`(Radix `Slot`), `className` 확장, focus-visi
## 11. Storybook ## 11. Storybook
- 대표 Story `All`("Button")이 기본 sidebar의 유일한 Button 항목이다 → 1뎁스. - 대표 Story `All`("Button")이 기본 sidebar의 유일한 Button 항목이다 → 1뎁스.
- `All`의 구역: Overview → Sizes → Variants → States. 기본 버튼·크기·허용 조합·Brand/Standard 상태를 보여준다. Red Solid와 Outline은 Variants와 Standard States에도 포함되며, 삭제·취소 세트와 Long Label은 verification Story에서 따로 확인한다. - `All`의 구역: Sizes → Variants → States(2026-11-09 Overview 제거; 단독 Overview 버튼은 Sizes의 Brand Medium과 같아 통합). 기본 버튼·크기·허용 조합·Brand/Standard 상태를 보여준다. Red Solid와 Outline은 Variants와 Standard States에도 포함되며, 삭제·취소 세트와 Long Label은 verification Story에서 따로 확인한다. PC에서는 Sizes·Variants가 같은 3열(Brand / Standard Solid / Standard Outline)을 쓰고 States의 Solid·Outline 표는 좌우 2열이다.
- verification(`tags: ["verification"]`, 기본 sidebar 제외): `Roles`, `Sizes`, `States`("Brand States"), `StandardStates`, `LongLabel`, `KoreanLabels`, `IconTextNarrow`, `Destructive`, `Playground`(HM 전용), `Compatibility`(ghost/link, default/outline 별칭). Controls는 조합별 선택을 제한하지 않으므로 §6.1의 허용 조합을 직접 지킨다. Verification은 기술 점검용이며 운영 화면의 사용 위치·세트 규칙은 §5가 우선한다. - verification(`tags: ["verification"]`, 기본 sidebar 제외): `Roles`, `Sizes`, `States`("Brand States"), `StandardStates`, `LongLabel`, `KoreanLabels`, `IconTextNarrow`, `Destructive`, `Playground`(HM 전용), `Compatibility`(ghost/link, default/outline 별칭). Controls는 조합별 선택을 제한하지 않으므로 §6.1의 허용 조합을 직접 지킨다. Verification은 기술 점검용이며 운영 화면의 사용 위치·세트 규칙은 §5가 우선한다.
- 대표 경로/ID: `Components/Form/Button → All` (`components-form-button--all`), `Components/Overlay/Dialog → All` (`components-overlay-dialog--all`), `Components/Overlay/Alert Dialog → All` (`components-overlay-alert-dialog--all`), `Patterns/Data/Filter Bar → Filter Bar` (`patterns-data-filter-bar--all`). 삭제 세트 점검 ID는 `components-form-button--destructive`(verification)다. - 대표 경로/ID: `Components/Form/Button → All` (`components-form-button--all`), `Components/Overlay/Dialog → All` (`components-overlay-dialog--all`), `Components/Overlay/Alert Dialog → All` (`components-overlay-alert-dialog--all`), `Patterns/Data/Filter Bar → Filter Bar` (`patterns-data-filter-bar--all`). 삭제 세트 점검 ID는 `components-form-button--destructive`(verification)다.
- 운영 규칙은 `프로젝트-지침.md` §6.3, §7 참조. - 운영 규칙은 `프로젝트-지침.md` §6.3, §7 참조.
+2 -2
View File
@@ -100,8 +100,8 @@ Radix 구조·ARIA(`role=checkbox`, `aria-checked`)·키보드, `data-slot`, `cn
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Checkbox"): `unchecked`, `checked`, `indeterminate`, `hover`, `focus`, `error`, `disabled`, `long`. Focus는 `play`로 실제 focus. `indeterminate`는 controlled 예시를 겸해 클릭하면 checked가 된다(Radix, 2026-10-22 별도 `interactive` 구역 통합). `hover`는 Unchecked·Checked를 Storybook 전용 `data-sb-hover`(`.storybook/pseudo-states.ts`)로 고정해 보여 준다. Checkbox API가 아니며 소비 코드에 붙이지 않는다. - 대표 Story `All`("Checkbox"): States `unchecked`, `checked`, `indeterminate`, `hover`, `focus`, `disabled`(PC 3열 × 2행)와 Validation `error`. Overview·Usage 구역은 2026-11-09에 없앴고 `long`(Long Label) 예시는 숨김 verification Story `LongLabel`로 옮겼다. 옵션 Label은 Story에서만 `--hm-label-text`를 `--foreground`로 덮어 기본 텍스트 색으로 보이며(공용 Label은 그대로, 비활성 Checkbox 옆 Label은 `peer-disabled`로 보조색 유지), 예시 제목은 보조색이다. Focus는 `play`로 실제 focus. `indeterminate`는 controlled 예시를 겸해 클릭하면 checked가 된다(Radix, 2026-10-22 별도 `interactive` 구역 통합). `hover`는 Unchecked·Checked를 Storybook 전용 `data-sb-hover`(`.storybook/pseudo-states.ts`)로 고정해 보여 준다. Checkbox API가 아니며 소비 코드에 붙이지 않는다.
- verification: `Unchecked`, `Checked`, `Indeterminate`, `Disabled`, `Error`, `Playground`. - verification: `Unchecked`, `Checked`, `Indeterminate`, `Disabled`, `Error`, `LongLabel`("Long Label"), `Playground`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1 -1
View File
@@ -88,7 +88,7 @@ Radix Label 동작·접근성, `data-slot`, `cn(..., className)`, `select-none`,
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Label"): 구역 `default`(htmlFor), `required`(소비 조합), `disabled`, `long`. - 대표 Story `All`("Label"): States(`default`(htmlFor), `disabled`)와 Usage(`select`(부서 선택), `required`(소비 조합), `long`)(2026-11-09 Overview 제거).
- 별도 verification Story 없음(구역이 단순). - 별도 verification Story 없음(구역이 단순).
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1 -1
View File
@@ -76,7 +76,7 @@ Radix 구조·ARIA(`role=menubar`, `menu`, `menuitem*`)·키보드 모델(Down/E
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Menubar"): 파일(item+shortcut, disabled, separator, sub menu 내보내기, destructive 종료), 편집, 보기(Label, Checkbox, Radio Group)를 가진 실제 bar. - 대표 Story `All`("Menubar"): 구역 제목은 Basic(2026-11-09 Overview에서 변경, 열린 메뉴용 고정 min-height 384px 제거). 파일(item+shortcut, disabled, separator, sub menu 내보내기, destructive 종료), 편집, 보기(Label, Checkbox, Radio Group)를 가진 실제 bar.
- 숨김 verification: `TriggerFocus`, `OpenFocus`. `OpenFocus`는 검수 목적으로 Story의 `play`에서 실제 열린 menu의 Trigger에 focus를 준다. 일반 Story/Production은 Radix 기본대로 메뉴 항목에 focus를 이동한다. - 숨김 verification: `TriggerFocus`, `OpenFocus`. `OpenFocus`는 검수 목적으로 Story의 `play`에서 실제 열린 menu의 Trigger에 focus를 준다. 일반 Story/Production은 Radix 기본대로 메뉴 항목에 focus를 이동한다.
- Focus 검수는 일반 Story의 computed style 기준으로 한다. Designer의 `.hm-designer button:focus-visible` 장식 outline을 컴포넌트 ring의 근거로 사용하지 않는다. - Focus 검수는 일반 Story의 computed style 기준으로 한다. Designer의 `.hm-designer button:focus-visible` 장식 outline을 컴포넌트 ring의 근거로 사용하지 않는다.
+1 -1
View File
@@ -72,7 +72,7 @@ Radix 구조·동작(hover open delay, Arrow 키와 Tab 이동, Escape 닫힘, f
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Navigation Menu"): `Viewport(기본)`과 `viewport={false}` 두 구성을 한 페이지에 둔다. 제품(설명 있는 3 링크), 자료(2열 4 링크), 회사(현재 위치 링크), 문의(일반 링크), 준비 중(disabled Trigger). - 대표 Story `All`("Navigation Menu"): Variants 한 구역에 `Viewport(기본)`과 `viewport={false}` 두 구성을 PC 균등 2열로 둔다(2026-11-09 Overview 제거, 닫힌 메뉴용 min-height 256px는 PC에서만 해제하고 1000px 미만 1열에서는 유지). 제품(설명 있는 3 링크), 자료(2열 4 링크), 회사(현재 위치 링크), 문의(일반 링크), 준비 중(disabled Trigger).
- verification: `Default`, `No Viewport`. - verification: `Default`, `No Viewport`.
- Story의 Content 폭은 `style` 인라인으로 지정한다(새 Story 파일의 Tailwind 임의값 클래스가 개발 서버에서 생성되지 않는 일이 있었다). - Story의 Content 폭은 `style` 인라인으로 지정한다(새 Story 파일의 Tailwind 임의값 클래스가 개발 서버에서 생성되지 않는 일이 있었다).
+2 -2
View File
@@ -27,7 +27,7 @@
| Page link | 34 × 34px(min-width 34), radius 4px(`--hm-radius-control-md`), 14 / 20 / 500, `--hm-sem-fg-primary` | | Page link | 34 × 34px(min-width 34), radius 4px(`--hm-radius-control-md`), 14 / 20 / 500, `--hm-sem-fg-primary` |
| Previous / Next | 높이 34px, padding `0 12px`, 아이콘 16px, 라벨 `hidden sm:block`("Previous"/"Next", 모바일은 아이콘만) | | Previous / Next | 높이 34px, padding `0 12px`, 아이콘 16px, 라벨 `hidden sm:block`("Previous"/"Next", 모바일은 아이콘만) |
| 간격 | 목록 gap 4px | | 간격 | 목록 gap 4px |
| Current Page | 배경 `--hm-sem-action-primary`(Primary Green `#123328`), 글자 `--hm-sem-on-action-primary`(흰색), weight 700 | | Current Page | 배경 `--hm-sem-action-primary`(Primary Green `#164434`), 글자 `--hm-sem-on-action-primary`(흰색), weight 700 |
| Ellipsis | 34 × 34 영역, 아이콘 16px, `--hm-sem-fg-secondary` | | Ellipsis | 34 × 34 영역, 아이콘 16px, `--hm-sem-fg-secondary` |
## 4. 상태 규격 ## 4. 상태 규격
@@ -67,7 +67,7 @@ Component Mapping 위치: `packages/ui/src/theme/pagination.css` (`--hm-paginati
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Pagination"): First page, Middle page, Last page, Long range with ellipsis(1 … 9 10 11 … 20)를 한 페이지에 둔다. 각 Specimen이 local state를 가지고 클릭 시 현재 페이지가 실제로 바뀐다(`#page-N` href + `preventDefault`). - 대표 Story `All`("Pagination"): Variants 한 구역에 First page, Middle page, Last page, Long range with ellipsis(1 … 9 10 11 … 20)를 PC 2열 × 2행으로 둔다(2026-11-09 Overview / States / Usage 통합, 버튼 묶음은 칸 안 왼쪽 정렬). 각 Specimen이 local state를 가지고 클릭 시 현재 페이지가 실제로 바뀐다(`#page-N` href + `preventDefault`).
- verification: `First Page`, `Last Page`, `Long Range`. - verification: `First Page`, `Last Page`, `Long Range`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1 -1
View File
@@ -57,7 +57,7 @@ shadcn `size-4 border-input text-primary shadow-xs dark:` class 제거 → theme
## 9. Storybook ## 9. Storybook
대표 Story `All`: `vertical`, `horizontal`, `hover`, `focus`, `error`, `disabled-item`, `disabled-group`. Focus는 `play`로 실제 focus. `hover`는 첫 항목을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 RadioGroupItem API가 아니다. verification: `Vertical`, `Horizontal`, `Disabled`, `Error`. 대표 Story `All`: Variants(`vertical`, `horizontal`; 2026-11-09 Overview 제거로 Vertical이 Variants 첫 항목), States(`hover`, `focus`, `disabled-item`, `disabled-group`), Validation(`error`). Focus는 `play`로 실제 focus. `hover`는 첫 항목을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 RadioGroupItem API가 아니다. verification: `Vertical`, `Horizontal`, `Disabled`, `Error`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1 -1
View File
@@ -98,7 +98,7 @@ Radix Root/Trigger/Portal/Content/Viewport/Item/ItemText 구조, 키보드 조
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Select"): Sizes(Medium / Large 비교, 각 Trigger로 실제 목록 열기) → States(`selected`, `disabled`, `disabled-placeholder`) → Invalid / Focus(`invalid`, `invalid-disabled`). 2026-10-22 Overview의 `default`·로드 시 열린 `open` 예시는 대표 화면에서 뺐다. 열린 목록은 숨김 `Open`, 크기별 수치 목록은 숨김 `Sizes`("Compare Sizes")에서 확인한다. size 생략 시 Large(§5)는 그대로다. - 대표 Story `All`("Select"): Sizes(Medium / Large 비교, 각 Trigger로 실제 목록 열기) → States(`selected`, `disabled`, `disabled-placeholder`) → Validation(`invalid`, `invalid-disabled`; 2026-11-09 `Invalid / Focus`에서 제목 변경, Focus 예시가 없다). PC에서는 세 구역이 같은 3열 칸을 쓰고 Sizes·Validation은 첫 두 칸만 채운다. 2026-10-22 Overview의 `default`·로드 시 열린 `open` 예시는 대표 화면에서 뺐다. 열린 목록은 숨김 `Open`, 크기별 수치 목록은 숨김 `Sizes`("Compare Sizes")에서 확인한다. size 생략 시 Large(§5)는 그대로다.
- verification(`tags: ["verification"]`): `Default`, `Open`, `Selected`, `Disabled`, `Sizes`("Compare Sizes"), `Medium`, `Large`, `LongContent`, `Scroll`, `Playground`(HM 전용). - verification(`tags: ["verification"]`): `Default`, `Open`, `Selected`, `Disabled`, `Sizes`("Compare Sizes"), `Medium`, `Large`, `LongContent`, `Scroll`, `Playground`(HM 전용).
- `Disabled` Story 구성: Trigger 비활성 상태들(placeholder 포함). - `Disabled` Story 구성: Trigger 비활성 상태들(placeholder 포함).
- `InvalidFocus` verification Story: 앞/뒤 버튼 사이의 Invalid Trigger와 Disabled Invalid Trigger. Tab / Shift+Tab, Enter / Space, Escape 및 focus return을 실제 키보드로 확인한다. - `InvalidFocus` verification Story: 앞/뒤 버튼 사이의 Invalid Trigger와 Disabled Invalid Trigger. Tab / Shift+Tab, Enter / Space, Escape 및 focus return을 실제 키보드로 확인한다.
+1 -1
View File
@@ -74,7 +74,7 @@ Radix 구조·동작(pointer drag, track 클릭 이동, 키보드, thumb 정렬/
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Slider"): Single(값 label), Range(두 thumb, `minStepsBetweenThumbs` 5), Step(25), Disabled, Vertical을 한 페이지에 둔다. 값 label은 Story 조합(local state)이다. - 대표 Story `All`("Slider"): Variants(Single, Range(두 thumb, `minStepsBetweenThumbs` 5), Step(25), Vertical)와 States(Disabled) 두 구역으로 둔다(2026-11-09 Overview 제거, PC 4열). 값 label은 Story 조합(local state)이다.
- verification: `Single`, `Range`, `Disabled`, `Focus`(실제 thumb focus). 기본 Sidebar에서 숨긴다. - verification: `Single`, `Range`, `Disabled`, `Focus`(실제 thumb focus). 기본 Sidebar에서 숨긴다.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1 -1
View File
@@ -56,7 +56,7 @@ shadcn의 `h-[1.15rem] w-8`, `border`, `shadow-xs`, `dark:` class 제거 → the
## 9. Storybook ## 9. Storybook
대표 Story `All`: `off`, `on`, `hover`, `focus`, `disabled`(2026-10-22 `interactive` 구역 제거, 모든 예시가 실제 클릭으로 토글). `hover`는 Off·On을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 Switch API가 아니다. Focus는 `play`로 실제 focus. verification: `Off`, `On`, `Disabled`. 대표 Story `All`: States 한 구역에 `off`, `on`, `hover`, `focus`, `disabled`(2026-11-09 Overview 제거, PC 5열; 2026-10-22 `interactive` 구역 제거, 모든 예시가 실제 클릭으로 토글). `hover`는 Off·On을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 Switch API가 아니다. Focus는 `play`로 실제 focus. verification: `Off`, `On`, `Disabled`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+4 -4
View File
@@ -67,8 +67,8 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@hm-design/ui"
| 상태 | 면 | 테두리 | 전경 | | 상태 | 면 | 테두리 | 전경 |
|---|---|---|---| |---|---|---|---|
| Default (`data-state="inactive"`) | `rgba(189,223,212,.45)` | top/right `rgba(38,102,81,.35)`, bottom `#DEDBD2` | `#266651` | | Default (`data-state="inactive"`) | `rgba(189,223,212,.45)` | top/right `rgba(38,102,81,.35)`, bottom `#DEDBD2` | `#266651` |
| Selected (`data-state="active"`) | `#FFFFFF` (`--hm-sem-bg-surface`) | top/right/left `#DEDBD2`, bottom 1px 표면색(기준선을 덮어 콘텐츠와 연결), `z-index: 2` | `#123328` | | Selected (`data-state="active"`) | `#FFFFFF` (`--hm-sem-bg-surface`) | top/right/left `#DEDBD2`, bottom 1px 표면색(기준선을 덮어 콘텐츠와 연결), `z-index: 2` | `#164434` |
| Hover (비선택만, `:hover`) | 기본 면 + Black 20% overlay (`linear-gradient`) | 변화 없음 | `#123328` | | Hover (비선택만, `:hover`) | 기본 면 + Black 20% overlay (`linear-gradient`) | 변화 없음 | `#164434` |
| Hover (선택) | 적용 안 함 | — | — | | Hover (선택) | 적용 안 함 | — | — |
| Focus (`:focus-visible`) | shadcn ring 유지 | — | — | | Focus (`:focus-visible`) | shadcn ring 유지 | — | — |
| Disabled | shadcn 기본 `opacity-50`, 클릭 불가 | — | — | | Disabled | shadcn 기본 `opacity-50`, 클릭 불가 | — | — |
@@ -126,12 +126,12 @@ Radix Tabs 구조·ARIA(`role=tablist/tab/tabpanel`, `aria-selected`)·roving ta
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Tabs"): **Overview**는 fill / panelSurface를 생략한 기본 Primary다. 같은 Primary가 반복되던 **Default API** 구역(`fill`만, `fill` + `panelSurface="content"` 명시)은 2026-10-22 숨김 verification `DefaultApi`로 옮겼다. **Variants**는 명시적 Secondary, **States**는 기본 Primary + 8개 탭 + 260px wrapper Overflow다. 기존 대표 Story ID와 메뉴를 유지한다. Hover는 실제 포인터로 확인한다. - 대표 Story `All`("Tabs"): **Variants**는 Primary(fill / panelSurface를 생략한 기본)·Secondary(`panelSurface="surface"`)·Overflow(20개 탭, 이전 States의 Overflow 예시)를 PC 균등 3열로 두고, **Usage**는 Card 내부 · Secondary / 페이지 직접 배치 · Primary 2열이다(2026-11-09 Overview·States 구역을 Variants로 통합, 이 페이지의 위아래 패딩만 줄여 1280×900 한 화면). 같은 Primary가 반복되던 **Default API** 구역(`fill`만, `fill` + `panelSurface="content"` 명시)은 2026-10-22 숨김 verification `DefaultApi`로 옮겼다. 기존 대표 Story ID와 메뉴를 유지한다. Hover는 실제 포인터로 확인한다.
- verification(`tags: ["verification"]`): `DefaultApi`, `Secondary`, `SelectedFirst`, `SelectedLast`, `Focus`, `Disabled`, `Overflow`, `SecondaryOverflow`, `LongLabel`. Primary 기준이며 Secondary는 같은 CSS 경로를 쓴다. Focus는 `play`로 실제 focus를 준다. - verification(`tags: ["verification"]`): `DefaultApi`, `Secondary`, `SelectedFirst`, `SelectedLast`, `Focus`, `Disabled`, `Overflow`, `SecondaryOverflow`, `LongLabel`. Primary 기준이며 Secondary는 같은 CSS 경로를 쓴다. Focus는 `play`로 실제 focus를 준다.
- `LongLabel`은 짧은 라벨, 긴 라벨 1개, 긴 라벨 여러 개, 260px 좁은 container를 함께 검증한다. 라벨은 `nowrap`·무말줄임이며 실제 필요 폭이 넘을 때 이동 버튼으로 전환한다. - `LongLabel`은 짧은 라벨, 긴 라벨 1개, 긴 라벨 여러 개, 260px 좁은 container를 함께 검증한다. 라벨은 `nowrap`·무말줄임이며 실제 필요 폭이 넘을 때 이동 버튼으로 전환한다.
- non-fill Story(`Selected`, `SelectedMiddle`, `Fill`, `FillContent`, `FillOverflow`와 대표의 non-fill 구역)는 2026-10-06에 제거했다. API(`fill=false`, Legacy profile CSS)는 유지한다(§10). - non-fill Story(`Selected`, `SelectedMiddle`, `Fill`, `FillContent`, `FillOverflow`와 대표의 non-fill 구역)는 2026-10-06에 제거했다. API(`fill=false`, Legacy profile CSS)는 유지한다(§10).
- 2026-10-08 숨김 `Compatibility`(`verification` 태그 + **VERIFICATION ONLY** 표시)는 Legacy `fill={false}` / `fill={false} panelSurface="surface"` 동일성과 `panelSurface="surface"`만 명시한 경우의 Secondary 전환을 확인한다. 일반 Story 메뉴를 추가하지 않는다. - 2026-10-08 숨김 `Compatibility`(`verification` 태그 + **VERIFICATION ONLY** 표시)는 Legacy `fill={false}` / `fill={false} panelSurface="surface"` 동일성과 `panelSurface="surface"`만 명시한 경우의 Secondary 전환을 확인한다. 일반 Story 메뉴를 추가하지 않는다.
- Overview / Variants / States, `DefaultApi` 및 Fill verification 예제의 콘텐츠 panel은 `px-0 py-6`로 통일해 D-01을 실제로 반영한다. Primary는 상위 content 면, Secondary는 상위 surface 면에 연결하며 내부 패널의 별도 border·배경·radius를 제거한다. Compatibility의 Legacy non-fill 두 예제만 이전 `border bg-card rounded-b p-4`를 보존한다. - Variants / Usage, `DefaultApi` 및 Fill verification 예제의 콘텐츠 panel은 `px-0 py-6`로 통일해 D-01을 실제로 반영한다. Primary는 상위 content 면, Secondary는 상위 surface 면에 연결하며 내부 패널의 별도 border·배경·radius를 제거한다. Compatibility의 Legacy non-fill 두 예제만 이전 `border bg-card rounded-b p-4`를 보존한다.
- **Usage**(2026-10-09 추가)는 공개 Card / Input / Textarea / Table을 조합해 Card 내부 Secondary와 페이지 content 면 위 Primary를 보여준다. 두 예제 모두 `TabsContent`는 `px-0 py-6`이며 별도 배경·border·radius를 추가하지 않는다. 대표 Story ID·기존 구역·verification Story는 유지한다. - **Usage**(2026-10-09 추가)는 공개 Card / Input / Textarea / Table을 조합해 Card 내부 Secondary와 페이지 content 면 위 Primary를 보여준다. 두 예제 모두 `TabsContent`는 `px-0 py-6`이며 별도 배경·border·radius를 추가하지 않는다. 대표 Story ID·기존 구역·verification Story는 유지한다.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+4 -4
View File
@@ -86,8 +86,8 @@ native `<textarea>` 동작 전체, `data-slot`, `cn(..., className)`, `transitio
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Textarea"): 구역 `default`, `focus`, `error`, `error-message`, `readonly`, `disabled`, `multiline`, `resize`. Focus는 `play` 함수로 실제 focus를 준다. Hover는 Default와 같아(TEXTAREA-05) 2026-10-22 대표 화면의 중복 `hover` 예시를 뺐다. 실제 포인터 확인은 Default 예시에서 한다. - 대표 Story `All`("Textarea"): States `default`, `focus`, `readonly`, `disabled`와 Validation `error`, `error-message`. `multiline`·`resize` 예시는 대표 페이지에서 빼고 숨김 verification Story `Multiline`·`ResizeNone`으로 옮겼다(2026-11-09). Focus는 `play` 함수로 실제 focus를 준다. Hover는 Default와 같아(TEXTAREA-05) 2026-10-22 대표 화면의 중복 `hover` 예시를 뺐다. 실제 포인터 확인은 Default 예시에서 한다.
- verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Readonly`, `Disabled`, `LongContent`, `Playground`(HM 전용). - verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Multiline`("Multiline · Wrap"), `ResizeNone`("Resize · none"), `Readonly`, `Disabled`, `LongContent`, `Playground`(HM 전용). `Multiline`·`ResizeNone`은 2026-11-09에 대표 Story의 Usage 구역에서 옮긴 것이다.
- Error Message·Required 표시는 Story 안의 조합 예시이며 Textarea 컴포넌트의 일부가 아니다. - Error Message·Required 표시는 Story 안의 조합 예시이며 Textarea 컴포넌트의 일부가 아니다.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
@@ -119,8 +119,8 @@ src/components/story-board.tsx
- `npm run typecheck`(root + ui + center-graphic), `npm run build`: 통과(2026-10-02). - `npm run typecheck`(root + ui + center-graphic), `npm run build`: 통과(2026-10-02).
- Storybook computed style(`components-form-textarea--all`): Default min-height 112px / padding 8px 12px / border 1px rgb(222,219,210) / radius 4px / bg white / 16·24·400 / resize vertical / Placeholder rgb(153,153,153), Error border rgb(211,47,47) + padding 7.5px 11.5px, Readonly resize none · opacity 1, `resize="none"` 적용, Disabled opacity 0.6. - Storybook computed style(`components-form-textarea--all`): Default min-height 112px / padding 8px 12px / border 1px rgb(222,219,210) / radius 4px / bg white / 16·24·400 / resize vertical / Placeholder rgb(153,153,153), Error border rgb(211,47,47) + padding 7.5px 11.5px, Readonly resize none · opacity 1, `resize="none"` 적용, Disabled opacity 0.6.
- 실제 클릭 focus: `:focus-visible` 매칭, border rgb(18,51,40), padding 7.5px 11.5px 확인. - 실제 클릭 focus: `:focus-visible` 매칭, border rgb(18,51,40), padding 7.5px 11.5px 확인.
- Story ID: `All` + verification 8개 존재 확인(index.json). `@hm-design/ui`에서 `import { Textarea }`로 Story가 빌드됨. - Story ID: `All` + verification 8개 존재 확인(index.json). 2026-11-09 기준 `All` + verification 12개(index.json 13개, `components-form-textarea--multiline`·`--resize-none` 추가, 기존 ID 유지). `@hm-design/ui`에서 `import { Textarea }`로 Story가 빌드됨.
- 2026-10-06 ui_viz Storybook 화면 비교(1280/768/390, Chromium): 컨트롤 computed style 일치(ui_viz Story의 114px은 `rows=4` 때문), Error+Focus outline 유지, 긴 내용은 내부 세로 scroll·가로 넘침 없음, Readonly 편집 불가·선택 가능, Disabled focus 불가. ui_viz는 Disabled에서 Field 전체(라벨 포함)를 흐리지만 HM은 컨트롤만 흐리고 라벨은 `Label`의 disabled 연동(소비 조합)이 맡는다 — 의도적 차이. 대표 Story는 ui_viz 5구역을 모두 포함한 상위 집합(Hover = Default, Error Message, Resize none 추가)이라 변경하지 않음. - 2026-10-06 ui_viz Storybook 화면 비교(1280/768/390, Chromium): 컨트롤 computed style 일치(ui_viz Story의 114px은 `rows=4` 때문), Error+Focus outline 유지, 긴 내용은 내부 세로 scroll·가로 넘침 없음, Readonly 편집 불가·선택 가능, Disabled focus 불가. ui_viz는 Disabled에서 Field 전체(라벨 포함)를 흐리지만 HM은 컨트롤만 흐리고 라벨은 `Label`의 disabled 연동(소비 조합)이 맡는다 — 의도적 차이. 대표 Story는 ui_viz 5구역을 모두 포함한 상위 집합(Hover = Default, Error Message, Resize none 추가)이라 변경하지 않음(당시 기록). 2026-11-09부터 대표 Story는 States·Validation만 보여 주고 Multiline·Resize none은 숨김 Story로 옮겼다(§9).
- 미확인: 브라우저별 렌더(Chromium 외). - 미확인: 브라우저별 렌더(Chromium 외).
+1 -1
View File
@@ -72,7 +72,7 @@ Radix 구조·ARIA·roving focus(방향키, Home/End, disabled 건너뜀, `loop`
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Toggle Group"): Single(local state), Multiple(icon-only, local state), Disabled group / item, Vertical(icon-only), `spacing={2}`, Many items(`spacing={1}` + `flex-wrap`)을 한 페이지에 둔다. 대표 화면은 예시 제목·선택값 표시(`value = …`)를 생략하고 구역 제목으로 구분한다. 값 표시는 verification에 남긴다. - 대표 Story `All`("Toggle Group"): Variants(Single(local state), Multiple(icon-only, local state), Vertical(icon-only), `spacing={2}`), Sizes, States(Disabled group / item)를 둔다(2026-11-09 Overview 제거). `spacing={1}` + `flex-wrap`의 Many items 예시는 대표 화면에서 빼고 숨김 verification Story `ManyOptions`("Many Options")로 옮겼다. 대표 화면은 예시 제목·선택값 표시(`value = …`)를 생략하고 구역 제목으로 구분한다. 값 표시는 verification에 남긴다.
- verification: `Single`, `Multiple`, `Vertical`. - verification: `Single`, `Multiple`, `Vertical`.
- meta에 `component`를 지정하지 않는다(props가 `single | multiple` union이라 args 없는 Story가 타입을 통과하지 못함). - meta에 `component`를 지정하지 않는다(props가 `single | multiple` union이라 args 없는 Story가 타입을 통과하지 못함).
+1 -1
View File
@@ -69,7 +69,7 @@ Radix 구조·`aria-pressed`·`data-state`·Enter/Space·controlled/uncontrolled
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Toggle"): Overview(Off/On, default·outline), 단독 · 연결 그룹(단독 Toggle, `spacing=0` ToggleGroup 단일·다중 선택), Sizes, States(Disabled off/on). 2026-10-22 Icon-only·icon + text 예시는 숨김 `IconOnly`로만 확인하고, Controlled(`pressed` 표시)·className 확장 예시는 대표 화면에서 제거했다. `pressed` / `onPressedChange`와 `className` 확장 API(§5)는 그대로다. - 대표 Story `All`("Toggle"): Variants(Off/On, default·outline)와 States(Disabled off/on), 단독 · 연결 그룹(단독 Toggle, `spacing=0` ToggleGroup 단일·다중 선택), Sizes(2026-11-09 Overview를 Variants로 바꾸고 PC 3열 기준으로 재배치). 2026-10-22 Icon-only·icon + text 예시는 숨김 `IconOnly`로만 확인하고, Controlled(`pressed` 표시)·className 확장 예시는 대표 화면에서 제거했다. `pressed` / `onPressedChange`와 `className` 확장 API(§5)는 그대로다.
- verification: `States`, `Icon Only`, `Disabled`. - verification: `States`, `Icon Only`, `Disabled`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+7 -7
View File
@@ -8,10 +8,10 @@
| Light 3 | #249473 | #C1A770 | #EC8F53 | | Light 3 | #249473 | #C1A770 | #EC8F53 |
| Light 2 | #266651 | #897445 | #F87725 | | Light 2 | #266651 | #897445 | #F87725 |
| Light 1 | #205241 | #604F32 | #EB5F00 | | Light 1 | #205241 | #604F32 | #EB5F00 |
| Default | #123328 | #3E3523 | #CC5200 | | Default | #164434 | #4E422D | #CC5200 |
| Dark 1 | #0A221D | #352D1D | #BC4C00 | | Dark 1 | #11372D | #403726 | #BC4C00 |
| Dark 2 | #071E18 | #2F281C | #883700 | | Dark 2 | #0B2B23 | #2F281C | #883700 |
| Dark 3 | #020A07 | #1D1810 | #4F2000 | | Dark 3 | #030E0A | #0D0B07 | #4F2000 |
모든 칸의 Key는 `color.palette.{green|brown|orange}-{light-4|light-3|light-2|light-1|default|dark-1|dark-2|dark-3}`로 선언되어 있다. CSS는 동일 suffix의 `--hm-sem-palette-*` → `--hm-*` Primitive를 사용한다. Orange Dark 1·2 확정 당시에는 83개(색상 81개 + radius 2개)였으며 이후 Generic Status Error 1개와 Brown/Orange Background Stop 10개가 추가됐다. 현재 공개 입력은 94개(색상 92개 + radius 2개)다. Dark 3와 Light 4~Default 값은 유지한다. 모든 칸의 Key는 `color.palette.{green|brown|orange}-{light-4|light-3|light-2|light-1|default|dark-1|dark-2|dark-3}`로 선언되어 있다. CSS는 동일 suffix의 `--hm-sem-palette-*` → `--hm-*` Primitive를 사용한다. Orange Dark 1·2 확정 당시에는 83개(색상 81개 + radius 2개)였으며 이후 Generic Status Error 1개와 Brown/Orange Background Stop 10개가 추가됐다. 현재 공개 입력은 94개(색상 92개 + radius 2개)다. Dark 3와 Light 4~Default 값은 유지한다.
@@ -33,8 +33,8 @@ Orange Dark 1·2의 원본 Needs Review 상태와 [이전 검수 보고서](../r
## 호환성과 Storybook ## 호환성과 Storybook
페이지 소개는 `한맥 브랜드 컬러`로 표시한다. 그룹 설명의 단계 순서 나열 문구는 제공하지 않으며, 개별 컬러칩의 단계명과 HEX는 유지한다. 페이지 제목은 `Hanmac Color`(메뉴 `Foundation > Hanmac Color`)이며 별도 소개 문구는 표시하지 않는다. 기존 Story ID `foundation-color--all`은 `meta.id`로 유지한다. 과거 `한맥 브랜드 컬러` 소개 문구는 당시 이력이다. 그룹 설명의 단계 순서 나열 문구는 제공하지 않으며, 개별 컬러칩의 단계명과 HEX는 유지한다.
[Color 대표 Story](../../src/foundation/color.stories.tsx)는 한맥그린·한맥브라운·한맥오렌지 3개 그룹에 각 8개, 총 24개 컬러칩만 표시한다. Green 8/8·Brown 8/8·Orange 8/8 확정이며 단계명·현재 HEX와 Default 기본색 강조를 유지한다. 2026-10-11 최종 표시 요청에 따라 Brown Deep, Orange Light·Mid·Dark·Deep의 별도 표시, Accent·Semantic·Gradients 섹션과 개발용 Token·CSS Variable·Runtime Key 설명은 제거한다. Legacy Button 색상과 Accent·Semantic·Gradient의 실제 Key·값·소비처는 보존한다. 기존 Component mapping을 새 값에 연결하지 않으며 public Key·Property·type·Default·API·schemaVersion 1과 서비스 partial JSON 호환성을 유지한다. [Runtime Theme](runtime-theme.md)의 geometry/상태 권한과 Designer의 기존 17개 상위 역할 입력 UI는 확대하지 않는다. 한맥오렌지의 미확정 브랜드 단계는 없다. [표시 정리 검증 기록](../reports/foundation-color-display-cleanup-2026-10-11.md). [Hanmac Color 대표 Story](../../src/foundation/color.stories.tsx)는 한맥그린·한맥브라운·한맥오렌지 3개 그룹에 각 8개, 총 24개 컬러칩만 표시한다. Green 8/8·Brown 8/8·Orange 8/8 확정이며 단계명·현재 HEX와 Default 기본색 강조를 유지한다. 2026-10-11 최종 표시 요청에 따라 Brown Deep, Orange Light·Mid·Dark·Deep의 별도 표시, Accent·Semantic·Gradients 섹션과 개발용 Token·CSS Variable·Runtime Key 설명은 제거한다. Legacy Button 색상과 Accent·Semantic·Gradient의 실제 Key·값·소비처는 보존한다. 기존 Component mapping을 새 값에 연결하지 않으며 public Key·Property·type·Default·API·schemaVersion 1과 서비스 partial JSON 호환성을 유지한다. [Runtime Theme](runtime-theme.md)의 geometry/상태 권한과 Designer의 기존 17개 상위 역할 입력 UI는 확대하지 않는다. 한맥오렌지의 미확정 브랜드 단계는 없다. [표시 정리 검증 기록](../reports/foundation-color-display-cleanup-2026-10-11.md).
[Company Colors Story](../../src/foundation/company-colors.stories.tsx)는 Foundation > Color와 별도 메뉴(Color 바로 아래)이며 7개 계열사·14개 컬러를 회사별로 표시한다. 데이터는 OrgChart의 기존 `companyColorPresets`를 그대로 읽고(메인 → 서브 순서, 메인은 Default 강조 재사용) HEX를 중복 정의하지 않는다. Theme 토큰·`themes/*.json`·공개 계약은 변경하지 않으며 Foundation > Color의 3그룹 24칩은 그대로다. [Company Colors Story](../../src/foundation/company-colors.stories.tsx)는 Foundation > Hanmac Color와 별도 메뉴(Hanmac Color 바로 아래)이며 7개 계열사·14개 컬러를 회사별로 표시한다(별도 소개 문구 없음). 데이터는 OrgChart의 기존 `companyColorPresets`를 그대로 읽고(메인 → 서브 순서, 메인은 Default 강조 재사용) HEX를 중복 정의하지 않는다. Theme 토큰·`themes/*.json`·공개 계약은 변경하지 않으며 Foundation > Hanmac Color의 3그룹 24칩은 그대로다.
+15 -15
View File
@@ -6,24 +6,24 @@
|---|---|---|---|---|---| |---|---|---|---|---|---|
| `color.bg.content` | `--hm-sem-bg-content` | `#f5f0e5` | 콘텐츠 면 | Yes | gnb, lnb | | `color.bg.content` | `--hm-sem-bg-content` | `#f5f0e5` | 콘텐츠 면 | Yes | gnb, lnb |
| `color.bg.surface` | `--hm-sem-bg-surface` | `#ffffff` | 기본/카드/입력 면 | Yes | checkbox, radio, switch, slider, badge, table, input, select, button, gnb | | `color.bg.surface` | `--hm-sem-bg-surface` | `#ffffff` | 기본/카드/입력 면 | Yes | checkbox, radio, switch, slider, badge, table, input, select, button, gnb |
| `color.fg.primary` | `--hm-sem-fg-primary` | `#0a221d` | 주요 텍스트 | Yes | calendar, pagination, badge, table, input, select, gnb | | `color.fg.primary` | `--hm-sem-fg-primary` | `#11372d` | 주요 텍스트 | Yes | calendar, pagination, badge, table, input, select, gnb |
| `color.fg.secondary` | `--hm-sem-fg-secondary` | `#6e6759` | 보조 텍스트 | Yes | calendar, pagination, badge, table, gnb | | `color.fg.secondary` | `--hm-sem-fg-secondary` | `#6e6759` | 보조 텍스트 | Yes | calendar, pagination, badge, table, gnb |
| `color.fg.placeholder` | `--hm-sem-fg-placeholder` | `#999999` | Placeholder | Yes | input, select | | `color.fg.placeholder` | `--hm-sem-fg-placeholder` | `#999999` | Placeholder | Yes | input, select |
| `color.action.primary` | `--hm-sem-action-primary` | `#123328` | Strong / Primary ink | Yes | checkbox/radio hover, slider Hover/Drag halo, pagination current, badge default, button outline, navigation current ink | | `color.action.primary` | `--hm-sem-action-primary` | `#164434` | Strong / Primary ink | Yes | checkbox/radio hover, slider Hover/Drag halo, pagination current, badge default, button outline, navigation current ink |
| `color.action.cancel` | `--hm-sem-action-cancel` | `#6e6759` | 공개 취소 역할 | Yes | Button `variant="cancel"` + `appearance="outline"` | | `color.action.cancel` | `--hm-sem-action-cancel` | `#6e6759` | 공개 취소 역할 | Yes | Button `variant="cancel"` + `appearance="outline"` |
| `color.action.destructive` | `--hm-sem-action-destructive` | `#d32f2f` | 오류/위험 역할 | Yes | checkbox, radio, badge, input, button | | `color.action.destructive` | `--hm-sem-action-destructive` | `#d32f2f` | 오류/위험 역할 | Yes | checkbox, radio, badge, input, button |
| `color.status.error` | `--hm-sem-status-error` | `#b3261e` | Generic Status Error · 일반 상태 오류 · Provisional | Yes | Foundation Color / 선택적 Theme 입력; 기존 Component 자동 연결 없음 | | `color.status.error` | `--hm-sem-status-error` | `#b3261e` | Generic Status Error · 일반 상태 오류 · Provisional | Yes | Foundation Color / 선택적 Theme 입력; 기존 Component 자동 연결 없음 |
| `color.stroke.default` | `--hm-sem-stroke-default` | `#9a9284` | 기본 border | Yes | checkbox, radio, switch, badge | | `color.stroke.default` | `--hm-sem-stroke-default` | `#9a9284` | 기본 border | Yes | checkbox, radio, switch, badge |
| `color.stroke.input.default` | `--hm-sem-stroke-input-default` | `#dedbd2` | 입력/패널 divider border | Yes | table, input, select, GNB 콘텐츠 LNB divider | | `color.stroke.input.default` | `--hm-sem-stroke-input-default` | `#dedbd2` | 입력/패널 divider border | Yes | table, input, select, GNB 콘텐츠 LNB divider |
| `color.stroke.input.focus` | `--hm-sem-stroke-input-focus` | `#266651` | 공통 Focus border/ring | Yes | input, select, button, checkbox, calendar, slider Keyboard Focus halo 등; GNB/LNB는 contextual 예외 | | `color.stroke.input.focus` | `--hm-sem-stroke-input-focus` | `#266651` | 공통 Focus border/ring | Yes | input, select, button, checkbox, calendar, slider Keyboard Focus halo 등; GNB/LNB는 contextual 예외 |
| `color.bg.brand.stop.0` | `--hm-sem-brand-bg-stop-0` | `#0a221d` | 기존 gradient stop: color.bg.brand.stop.0 | Yes | Theme palette / adapter | | `color.bg.brand.stop.0` | `--hm-sem-brand-bg-stop-0` | `#11372d` | 기존 gradient stop: color.bg.brand.stop.0 | Yes | Theme palette / adapter |
| `color.bg.brand.stop.12` | `--hm-sem-brand-bg-stop-12` | `#071e18` | 기존 gradient stop: color.bg.brand.stop.12 | Yes | Theme palette / adapter | | `color.bg.brand.stop.12` | `--hm-sem-brand-bg-stop-12` | `#0b2b23` | 기존 gradient stop: color.bg.brand.stop.12 | Yes | Theme palette / adapter |
| `color.bg.brand.stop.42` | `--hm-sem-brand-bg-stop-42` | `#123328` | 기존 gradient stop: color.bg.brand.stop.42 | Yes | Theme palette / adapter | | `color.bg.brand.stop.42` | `--hm-sem-brand-bg-stop-42` | `#164434` | 기존 gradient stop: color.bg.brand.stop.42 | Yes | Theme palette / adapter |
| `color.bg.brand.stop.78` | `--hm-sem-brand-bg-stop-78` | `#266651` | 기존 gradient stop: color.bg.brand.stop.78 | Yes | Theme palette / adapter | | `color.bg.brand.stop.78` | `--hm-sem-brand-bg-stop-78` | `#266651` | 기존 gradient stop: color.bg.brand.stop.78 | Yes | Theme palette / adapter |
| `color.bg.brand.stop.100` | `--hm-sem-brand-bg-stop-100` | `#205241` | 기존 gradient stop: color.bg.brand.stop.100 | Yes | Theme palette / adapter | | `color.bg.brand.stop.100` | `--hm-sem-brand-bg-stop-100` | `#205241` | 기존 gradient stop: color.bg.brand.stop.100 | Yes | Theme palette / adapter |
| `color.bg.brown.stop.0` | `--hm-sem-brown-bg-stop-0` | `#352d1d` | Brown Background · Dark 1 | Yes · 5개 세트 | 공통 Background / Foundation Color | | `color.bg.brown.stop.0` | `--hm-sem-brown-bg-stop-0` | `#403726` | Brown Background · Dark 1 | Yes · 5개 세트 | 공통 Background / Foundation Color |
| `color.bg.brown.stop.12` | `--hm-sem-brown-bg-stop-12` | `#2f281c` | Brown Background · Dark 2 | Yes · 5개 세트 | 공통 Background / Foundation Color | | `color.bg.brown.stop.12` | `--hm-sem-brown-bg-stop-12` | `#2f281c` | Brown Background · Dark 2 | Yes · 5개 세트 | 공통 Background / Foundation Color |
| `color.bg.brown.stop.42` | `--hm-sem-brown-bg-stop-42` | `#3e3523` | Brown Background · Default | Yes · 5개 세트 | 공통 Background / Foundation Color | | `color.bg.brown.stop.42` | `--hm-sem-brown-bg-stop-42` | `#4e422d` | Brown Background · Default | Yes · 5개 세트 | 공통 Background / Foundation Color |
| `color.bg.brown.stop.78` | `--hm-sem-brown-bg-stop-78` | `#897445` | Brown Background · Light 2 | Yes · 5개 세트 | 공통 Background / Foundation Color | | `color.bg.brown.stop.78` | `--hm-sem-brown-bg-stop-78` | `#897445` | Brown Background · Light 2 | Yes · 5개 세트 | 공통 Background / Foundation Color |
| `color.bg.brown.stop.100` | `--hm-sem-brown-bg-stop-100` | `#604f32` | Brown Background · Light 1 | Yes · 5개 세트 | 공통 Background / Foundation Color | | `color.bg.brown.stop.100` | `--hm-sem-brown-bg-stop-100` | `#604f32` | Brown Background · Light 1 | Yes · 5개 세트 | 공통 Background / Foundation Color |
| `color.bg.orange.stop.0` | `--hm-sem-orange-bg-stop-0` | `#bc4c00` | Orange Background · Dark 1 | Yes · 5개 세트 | 공통 Background / Foundation Color | | `color.bg.orange.stop.0` | `--hm-sem-orange-bg-stop-0` | `#bc4c00` | Orange Background · Dark 1 | Yes · 5개 세트 | 공통 Background / Foundation Color |
@@ -35,7 +35,7 @@
| `surface.action.primary.stop.30` | `--hm-sem-action-surface-stop-30` | `#165c47` | 기존 gradient stop: surface.action.primary.stop.30 | Yes | button, gnb | | `surface.action.primary.stop.30` | `--hm-sem-action-surface-stop-30` | `#165c47` | 기존 gradient stop: surface.action.primary.stop.30 | Yes | button, gnb |
| `surface.action.primary.stop.65` | `--hm-sem-action-surface-stop-65` | `#114838` | 기존 gradient stop: surface.action.primary.stop.65 | Yes | button, gnb | | `surface.action.primary.stop.65` | `--hm-sem-action-surface-stop-65` | `#114838` | 기존 gradient stop: surface.action.primary.stop.65 | Yes | button, gnb |
| `surface.action.primary.stop.100` | `--hm-sem-action-surface-stop-100` | `#08241c` | 기존 gradient stop: surface.action.primary.stop.100 | Yes | button, gnb | | `surface.action.primary.stop.100` | `--hm-sem-action-surface-stop-100` | `#08241c` | 기존 gradient stop: surface.action.primary.stop.100 | Yes | button, gnb |
| `surface.action.primary.border.stop.0` | `--hm-sem-action-border-stop-0` | `#0a221d` | 기존 gradient stop: surface.action.primary.border.stop.0 | Yes | button | | `surface.action.primary.border.stop.0` | `--hm-sem-action-border-stop-0` | `#11372d` | 기존 gradient stop: surface.action.primary.border.stop.0 | Yes | button |
| `surface.action.primary.border.stop.50` | `--hm-sem-action-border-stop-50` | `#34daaa` | 기존 gradient stop: surface.action.primary.border.stop.50 | Yes | button | | `surface.action.primary.border.stop.50` | `--hm-sem-action-border-stop-50` | `#34daaa` | 기존 gradient stop: surface.action.primary.border.stop.50 | Yes | button |
| `surface.action.primary.border.stop.100` | `--hm-sem-action-border-stop-100` | `#08241c` | 기존 gradient stop: surface.action.primary.border.stop.100 | Yes | button | | `surface.action.primary.border.stop.100` | `--hm-sem-action-border-stop-100` | `#08241c` | 기존 gradient stop: surface.action.primary.border.stop.100 | Yes | button |
| `radius.input.default` | `--hm-radius-input` | `4px` | Input/Select radius | Yes | input, select | | `radius.input.default` | `--hm-radius-input` | `4px` | Input/Select radius | Yes | input, select |
@@ -50,13 +50,13 @@
| `color.palette.green-light-3` | `--hm-sem-palette-green-light-3` | `#249473` | 선택적 Theme 팔레트: green-light-3 | Yes | data-table Sort Direction Accent, tabs 장식, lnb gradient | | `color.palette.green-light-3` | `--hm-sem-palette-green-light-3` | `#249473` | 선택적 Theme 팔레트: green-light-3 | Yes | data-table Sort Direction Accent, tabs 장식, lnb gradient |
| `color.palette.green-light-2` | `--hm-sem-palette-green-light-2` | `#266651` | 선택적 Theme 팔레트: green-light-2 | Yes | checkbox, radio, switch, slider, progress, calendar, table, input, select, gnb, lnb | | `color.palette.green-light-2` | `--hm-sem-palette-green-light-2` | `#266651` | 선택적 Theme 팔레트: green-light-2 | Yes | checkbox, radio, switch, slider, progress, calendar, table, input, select, gnb, lnb |
| `color.palette.green-light-1` | `--hm-sem-palette-green-light-1` | `#205241` | 선택적 Theme 팔레트: green-light-1 | Yes | table, button, lnb | | `color.palette.green-light-1` | `--hm-sem-palette-green-light-1` | `#205241` | 선택적 Theme 팔레트: green-light-1 | Yes | table, button, lnb |
| `color.palette.green-default` | `--hm-sem-palette-green-default` | `#123328` | 선택적 Theme 팔레트: green-default | Yes | checkbox, radio, slider, calendar, pagination, badge, select, button, gnb, lnb | | `color.palette.green-default` | `--hm-sem-palette-green-default` | `#164434` | 선택적 Theme 팔레트: green-default | Yes | checkbox, radio, slider, calendar, pagination, badge, select, button, gnb, lnb |
| `color.palette.green-dark-1` | `--hm-sem-palette-green-dark-1` | `#0a221d` | 선택적 Theme 팔레트: green-dark-1 | Yes | calendar, pagination, badge, table, input, select, button, gnb | | `color.palette.green-dark-1` | `--hm-sem-palette-green-dark-1` | `#11372d` | 선택적 Theme 팔레트: green-dark-1 | Yes | calendar, pagination, badge, table, input, select, button, gnb |
| `color.palette.green-dark-2` | `--hm-sem-palette-green-dark-2` | `#071e18` | 선택적 Theme 팔레트: green-dark-2 | Yes | Theme palette / adapter | | `color.palette.green-dark-2` | `--hm-sem-palette-green-dark-2` | `#0b2b23` | 선택적 Theme 팔레트: green-dark-2 | Yes | Theme palette / adapter |
| `color.palette.brown-light-2` | `--hm-sem-palette-brown-light-2` | `#897445` | 선택적 Theme 팔레트: brown-light-2 | Yes | button | | `color.palette.brown-light-2` | `--hm-sem-palette-brown-light-2` | `#897445` | 선택적 Theme 팔레트: brown-light-2 | Yes | button |
| `color.palette.brown-light-1` | `--hm-sem-palette-brown-light-1` | `#604f32` | 선택적 Theme 팔레트: brown-light-1 | Yes | button | | `color.palette.brown-light-1` | `--hm-sem-palette-brown-light-1` | `#604f32` | 선택적 Theme 팔레트: brown-light-1 | Yes | button |
| `color.palette.brown-default` | `--hm-sem-palette-brown-default` | `#3e3523` | 선택적 Theme 팔레트: brown-default | Yes | button | | `color.palette.brown-default` | `--hm-sem-palette-brown-default` | `#4e422d` | 선택적 Theme 팔레트: brown-default | Yes | button |
| `color.palette.brown-dark-1` | `--hm-sem-palette-brown-dark-1` | `#352d1d` | 선택적 Theme 팔레트: brown-dark-1 | Yes | button | | `color.palette.brown-dark-1` | `--hm-sem-palette-brown-dark-1` | `#403726` | 선택적 Theme 팔레트: brown-dark-1 | Yes | button |
| `color.palette.brown-light-4` | `--hm-sem-palette-brown-light-4` | `#ecd4a2` | 선택적 Theme 팔레트: brown-light-4 | Yes | Theme palette / adapter | | `color.palette.brown-light-4` | `--hm-sem-palette-brown-light-4` | `#ecd4a2` | 선택적 Theme 팔레트: brown-light-4 | Yes | Theme palette / adapter |
| `color.palette.orange-light-4` | `--hm-sem-palette-orange-light-4` | `#feba88` | 선택적 Theme 팔레트: orange-light-4 | Yes | button | | `color.palette.orange-light-4` | `--hm-sem-palette-orange-light-4` | `#feba88` | 선택적 Theme 팔레트: orange-light-4 | Yes | button |
| `color.palette.orange-default` | `--hm-sem-palette-orange-default` | `#cc5200` | 선택적 Theme 팔레트: orange-default | Yes | button | | `color.palette.orange-default` | `--hm-sem-palette-orange-default` | `#cc5200` | 선택적 Theme 팔레트: orange-default | Yes | button |
@@ -89,10 +89,10 @@
| `color.palette.gray-dark` | `--hm-sem-palette-gray-dark` | `#5a5a5a` | 선택적 Theme 팔레트: gray-dark | Yes | button | | `color.palette.gray-dark` | `--hm-sem-palette-gray-dark` | `#5a5a5a` | 선택적 Theme 팔레트: gray-dark | Yes | button |
| `color.palette.indicator` | `--hm-sem-palette-indicator` | `#2d2d2d` | 선택적 Theme 팔레트: indicator | Yes | select | | `color.palette.indicator` | `--hm-sem-palette-indicator` | `#2d2d2d` | 선택적 Theme 팔레트: indicator | Yes | select |
| `color.palette.green-dark-3` | `--hm-sem-palette-green-dark-3` | `#020a07` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette | | `color.palette.green-dark-3` | `--hm-sem-palette-green-dark-3` | `#030e0a` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
| `color.palette.brown-light-3` | `--hm-sem-palette-brown-light-3` | `#c1a770` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette | | `color.palette.brown-light-3` | `--hm-sem-palette-brown-light-3` | `#c1a770` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
| `color.palette.brown-dark-2` | `--hm-sem-palette-brown-dark-2` | `#2f281c` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette | | `color.palette.brown-dark-2` | `--hm-sem-palette-brown-dark-2` | `#2f281c` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
| `color.palette.brown-dark-3` | `--hm-sem-palette-brown-dark-3` | `#1d1810` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette | | `color.palette.brown-dark-3` | `--hm-sem-palette-brown-dark-3` | `#0d0b07` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
| `color.palette.orange-light-3` | `--hm-sem-palette-orange-light-3` | `#ec8f53` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette | | `color.palette.orange-light-3` | `--hm-sem-palette-orange-light-3` | `#ec8f53` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
| `color.palette.orange-light-2` | `--hm-sem-palette-orange-light-2` | `#f87725` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette | | `color.palette.orange-light-2` | `--hm-sem-palette-orange-light-2` | `#f87725` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
| `color.palette.orange-light-1` | `--hm-sem-palette-orange-light-1` | `#eb5f00` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette | | `color.palette.orange-light-1` | `--hm-sem-palette-orange-light-1` | `#eb5f00` | ui_viz 확정 브랜드 단계 | Yes | Color Reference / 선택적 Theme palette |
+1 -1
View File
@@ -281,7 +281,7 @@ HMDesignSystem의 핵심 원칙은 다음과 같다.
- Progress는 현재 Active와 색 정책이 같아 같은 Semantic을 쓴다. 의미가 달라지면 별도 Semantic으로 분리한다. - Progress는 현재 Active와 색 정책이 같아 같은 Semantic을 쓴다. 의미가 달라지면 별도 Semantic으로 분리한다.
- 대상 아님: Toggle / Toggle Group(on = `--accent` 면, 별도 정책), GNB·LNB·Navigation Menu current(기존 Strong Brand 언어), Error/Destructive, Button. - 대상 아님: Toggle / Toggle Group(on = `--accent` 면, 별도 정책), GNB·LNB·Navigation Menu current(기존 Strong Brand 언어), Error/Destructive, Button.
- **Persistent Selected Surface**: Toggle / Toggle Group의 Selected 및 Selected + Hover = `--accent` 100% / `--accent-foreground`(Git baseline `d101530` 매핑, 2026-10-06 원복). 아이콘은 전경을 따르고 border는 기존 default 투명 / outline input stroke를 유지한다. Active override에 영향받지 않고 Accent override를 따른다. Focus override는 별도 ring만 바꾼다. - **Persistent Selected Surface**: Toggle / Toggle Group의 Selected 및 Selected + Hover = `--accent` 100% / `--accent-foreground`(Git baseline `d101530` 매핑, 2026-10-06 원복). 아이콘은 전경을 따르고 border는 기존 default 투명 / outline input stroke를 유지한다. Active override에 영향받지 않고 Accent override를 따른다. Focus override는 별도 ring만 바꾼다.
- Color 역할 정리: Hover / Soft surface = `--accent` 45% (Select·Command·Combobox·Menu Item highlighted, Calendar hover·range middle, Pagination hover, Table row hover·selected) / Persistent Selected Surface = `--accent` 100%(Toggle·Toggle Group) / Menubar Top Trigger Highlighted·Open 및 Navigation Menu Trigger·Open = `--accent` 100% + On Accent / Active Control fill = `--hm-sem-state-active`(위 적용 대상) / Focus = Focus chain / Strong Brand·Navigation·Badge default·Pagination current = Primary Green(`--hm-sem-action-primary`, `#123328`) / Error = Destructive. - Color 역할 정리: Hover / Soft surface = `--accent` 45% (Select·Command·Combobox·Menu Item highlighted, Calendar hover·range middle, Pagination hover, Table row hover·selected) / Persistent Selected Surface = `--accent` 100%(Toggle·Toggle Group) / Menubar Top Trigger Highlighted·Open 및 Navigation Menu Trigger·Open = `--accent` 100% + On Accent / Active Control fill = `--hm-sem-state-active`(위 적용 대상) / Focus = Focus chain / Strong Brand·Navigation·Badge default·Pagination current = Primary Green(`--hm-sem-action-primary`, `#164434`) / Error = Destructive.
- Runtime 공개 계약(`packages/ui/src/theme/contract.ts`)에 Active/on Active를 포함했다. `src/theme/preview.ts`는 패키지 Runtime API의 호환 어댑터다. - Runtime 공개 계약(`packages/ui/src/theme/contract.ts`)에 Active/on Active를 포함했다. `src/theme/preview.ts`는 패키지 Runtime API의 호환 어댑터다.
- **Floating Surface Semantic**(`default.css`): `--hm-sem-floating-background|border-width|border-color|radius|shadow`. Dropdown Menu·Context Menu·Popover·Hover Card·Menubar content·Navigation Menu panel의 `--hm-<component>-*`가 이를 참조한다. Toast(Sonner)는 notification surface라 이를 쓰지 않고 `--hm-toast-*`를 따로 둔다(Component Token은 유지하며 컴포넌트별 override 가능). Date Picker·Combobox는 Popover를 조합하므로 별도 mapping이 없다. Tooltip(어두운 면)·Dialog/Alert Dialog/Sheet/Drawer(modal)·Select·Card·Command는 대상이 아니다. - **Floating Surface Semantic**(`default.css`): `--hm-sem-floating-background|border-width|border-color|radius|shadow`. Dropdown Menu·Context Menu·Popover·Hover Card·Menubar content·Navigation Menu panel의 `--hm-<component>-*`가 이를 참조한다. Toast(Sonner)는 notification surface라 이를 쓰지 않고 `--hm-toast-*`를 따로 둔다(Component Token은 유지하며 컴포넌트별 override 가능). Date Picker·Combobox는 Popover를 조합하므로 별도 mapping이 없다. Tooltip(어두운 면)·Dialog/Alert Dialog/Sheet/Drawer(modal)·Select·Card·Command는 대상이 아니다.
+3 -3
View File
@@ -162,9 +162,9 @@ ui-viz에서 확인한 구조(실제 파일: `compositions/dashboard-variant-spe
| 영역 | ui-viz 구조 | 현재 HM 대응 | 이번 구현 | | 영역 | ui-viz 구조 | 현재 HM 대응 | 이번 구현 |
|---|---|---|---| |---|---|---|---|
| 조립 | Variant A(LNB 272 + 콘텐츠), Variant B(GNB 70 + 콘텐츠), Variant C(Compact 48). **GNB+LNB 동시 사용 예 없음** | `DashboardLayout` | slot 방식. 둘 다 / 하나만 | | 조립 | Variant A(LNB 240 + 콘텐츠. 2026-11-09 이전 272), Variant B(GNB 70 + 콘텐츠), Variant C(Compact 48). **GNB+LNB 동시 사용 예 없음** | `DashboardLayout` | slot 방식. 둘 다 / 하나만 |
| GNB | 70px green, 콘텐츠와 12px 접점 | `GNB` | slot `header` | | GNB | 70px green, 콘텐츠와 12px 접점 | `GNB` | slot `header` |
| LNB | 272px green, 콘텐츠와 18px 접점 | `LNB` | slot `sidebar` | | LNB | 240px green(2026-11-09 이전 272), 콘텐츠와 18px 접점 | `LNB` | slot `sidebar` |
| Content surface | `#F5F0E5`, padding 34, 접점 쪽 radius(LNB 18 / GNB 12), 그림자 `-8px 0 18px`(LNB) · `0 -8px 18px`(GNB) rgba(7,30,24,.18), 별도 border 없음 | 신규 | `DashboardMain` | | Content surface | `#F5F0E5`, padding 34, 접점 쪽 radius(LNB 18 / GNB 12), 그림자 `-8px 0 18px`(LNB) · `0 -8px 18px`(GNB) rgba(7,30,24,.18), 별도 border 없음 | 신규 | `DashboardMain` |
| 스크롤 | "메뉴/헤더는 고정, 콘텐츠 영역에서만 스크롤"(Confirmed). `overflow-y:auto`, `overscroll-behavior: contain` | 신규 | `DashboardMain`이 스크롤 소유 | | 스크롤 | "메뉴/헤더는 고정, 콘텐츠 영역에서만 스크롤"(Confirmed). `overflow-y:auto`, `overscroll-behavior: contain` | 신규 | `DashboardMain`이 스크롤 소유 |
| Page heading | breadcrumb 12px(정적 `p`, 컴포넌트 아님) → 제목 28 → 설명, space-between·align end·gap 24, **하단 20px** | Breadcrumb + 신규 Pattern | `PageHeader` | | Page heading | breadcrumb 12px(정적 `p`, 컴포넌트 아님) → 제목 28 → 설명, space-between·align end·gap 24, **하단 20px** | Breadcrumb + 신규 Pattern | `PageHeader` |
@@ -260,7 +260,7 @@ Compact / Sidebar mobile hide: NONE
| 항목 | ui-viz | 현재 HM | 분류 | | 항목 | ui-viz | 현재 HM | 분류 |
|---|---|---|---| |---|---|---|---|
| GNB 70 / LNB 272 | 같음 | `--hm-gnb-height` / `--hm-lnb-width` | MATCHED | | GNB 70 / LNB 240 | 같음(LNB 폭은 2026-11-09 공식 규격 채택, ui-viz 272와 다름) | `--hm-gnb-height` / `--hm-lnb-width` | MATCHED |
| 콘텐츠 면 색·padding 34 | `#F5F0E5`, 34 | 같음 | MATCHED | | 콘텐츠 면 색·padding 34 | `#F5F0E5`, 34 | 같음 | MATCHED |
| 접점 radius (LNB 18 / GNB 12), shadow | 같음 | 같음. 소유를 Dashboard로 이동 | MATCHED | | 접점 radius (LNB 18 / GNB 12), shadow | 같음 | 같음. 소유를 Dashboard로 이동 | MATCHED |
| 헤더·메뉴 고정, 콘텐츠만 스크롤 | 같음 | `DashboardMain` 스크롤 | MATCHED | | 헤더·메뉴 고정, 콘텐츠만 스크롤 | 같음 | `DashboardMain` 스크롤 | MATCHED |
+1 -1
View File
@@ -70,7 +70,7 @@ Header·cell·row·selected 면은 `packages/ui/src/theme/table.css`(`--hm-table
## 9. Storybook ## 9. Storybook
- `Patterns/Data/Data Table` → `Data Table`(대표: Overview 선택·정렬·열 표시·행 액션·페이지 크기·페이지, `기본 목록 검색`, `추가 조건 목록 검색`), `With Filter Bar`(숨김 Filter Bar 연동 검증). 두 목록 검색 구역은 흰 Card 하나에 CardTitle(`사용자 목록`) → 검색·열 표시 도구 → 표 순서다. 페이지 제목 PageHeader를 쓰는 화면에서는 PageHeader를 이 Card 밖에 둔다. verification: `WithFilterBar`, `Loading`, `Empty`. - `Patterns/Data/Data Table` → `Data Table`(대표: `Advanced Search` 구역 하나. 추가 조건 목록 검색 + 선택·정렬·열 표시·행 액션·페이지 크기·페이지), 숨김 verification Story `Overview`(기본 표, `patterns-data-data-table--overview`), `Basic Search`(기본 목록 검색, `patterns-data-data-table--basic-search`), `With Filter Bar`(추가 조건 연동 검증). 목록 검색 구역은 흰 Card 하나에 CardTitle(`사용자 목록`) → 검색·열 표시 도구 → 표 순서다. 페이지 제목 PageHeader를 쓰는 화면에서는 PageHeader를 이 Card 밖에 둔다. verification: `WithFilterBar`, `Loading`, `Empty`.
- 샘플 데이터는 `src/patterns/sample-users.ts`(47행, 네트워크 없음). - 샘플 데이터는 `src/patterns/sample-users.ts`(47행, 네트워크 없음).
## 10. Accessibility ## 10. Accessibility
+4 -4
View File
@@ -112,7 +112,7 @@ ui-viz에서 GNB(Variant B, top-header)와 LNB(Variant A, sidebar)는 서로 대
**AI 구현 필수**: `GNBNav`는 모바일에서 숨겨질 뿐 모바일 메뉴를 자동 생성하지 않는다. 최초 구현부터 Desktop `GNBNav`와 Mobile `GNBMobileMenu`를 함께 넣고 같은 메뉴 데이터·현재 경로를 사용한다. `GNBMobileMenu`가 햄버거와 Sheet를 모두 제공하므로 별도 메뉴 버튼이나 Sheet를 추가하지 않는다. 기본 GNB는 `GNBMobileLink / GNBMobileGroup`을 사용한다. 링크 선택은 router의 `preventDefault()` 여부와 관계없이 Sheet를 닫으며, 닫기 버튼·Escape·focus trap·메뉴 버튼으로의 포커스 복귀는 기존 Sheet/Radix 동작을 유지한다. **AI 구현 필수**: `GNBNav`는 모바일에서 숨겨질 뿐 모바일 메뉴를 자동 생성하지 않는다. 최초 구현부터 Desktop `GNBNav`와 Mobile `GNBMobileMenu`를 함께 넣고 같은 메뉴 데이터·현재 경로를 사용한다. `GNBMobileMenu`가 햄버거와 Sheet를 모두 제공하므로 별도 메뉴 버튼이나 Sheet를 추가하지 않는다. 기본 GNB는 `GNBMobileLink / GNBMobileGroup`을 사용한다. 링크 선택은 router의 `preventDefault()` 여부와 관계없이 Sheet를 닫으며, 닫기 버튼·Escape·focus trap·메뉴 버튼으로의 포커스 복귀는 기존 Sheet/Radix 동작을 유지한다.
Reference는 [대표 Story 소스](../../src/patterns/gnb.stories.tsx)의 `All`(`Patterns/Layout/GNB → GNB`, `patterns-layout-gnb--all`) 안 Overview·Usage · GNB Sidebar를 좁은 폭에서 확인한다. `Mobile / Mobile Menu Open`은 숨김 verification이며 동작 검사에만 사용한다. Sidebar를 포함하면 [§15의 공개 API 소비 예제](#15-gnb-only-sidebar-composition-2026-10-08)를 따른다. LNB 모바일 링크 재사용 시 자동 닫기 Context가 다르므로 controlled Sheet를 route handler에서 닫고 Desktop 복귀 시에도 닫는다. 이는 GNB ONLY 내부 Composition이며 별도 LNB ONLY 모드가 아니다. Reference는 [대표 Story 소스](../../src/patterns/gnb.stories.tsx)의 `All`(`Patterns/Layout/GNB → GNB`, `patterns-layout-gnb--all`) 안 Variants(Default, Long Labels)·States(Navigation Menu Open)를 좁은 폭에서 확인하고, With Logo·Utilities는 숨김 `With Logo`·`Utilities` Story에서 확인하며, GNB Sidebar는 숨김 `GNB Sidebar`(`patterns-layout-gnb--sidebar`)·`Brown Sidebar`(`patterns-layout-gnb--brown-sidebar`), Sticky는 숨김 `Sticky` Story에서 확인한다(2026-11-09: 대표 페이지의 Usage 3개를 개별 verification Story로 분리). `Mobile / Mobile Menu Open`은 숨김 verification이며 동작 검사에만 사용한다. Sidebar를 포함하면 [§15의 공개 API 소비 예제](#15-gnb-only-sidebar-composition-2026-10-08)를 따른다. LNB 모바일 링크 재사용 시 자동 닫기 Context가 다르므로 controlled Sheet를 route handler에서 닫고 Desktop 복귀 시에도 닫는다. 이는 GNB ONLY 내부 Composition이며 별도 LNB ONLY 모드가 아니다.
- Desktop(1024px 이상): 위 표. 높이 70, 좌우 28, column gap 24. - Desktop(1024px 이상): 위 표. 높이 70, 좌우 28, column gap 24.
- Mobile(1023px 이하): 높이 56, padding 16. 주 메뉴·eyebrow·사용자 글자를 숨기고 햄버거(흰 ghost Button, `aria-label`)를 맨 앞에, 사용자는 같은 Avatar 하나만 보인다(중복 없음). 햄버거 → HM Sheet(left): 제목, 링크(44px), 그룹 제목. Radix가 focus trap·Escape·focus 복귀를 맡는다. breakpoint를 768에서 1024로 올린 이유: 메뉴 4개(항목 최소 112) 가운데 정렬은 그 이하에서 brand/profile이 너무 좁아진다. ui-viz에는 반응형 기준이 없다. - Mobile(1023px 이하): 높이 56, padding 16. 주 메뉴·eyebrow·사용자 글자를 숨기고 햄버거(흰 ghost Button, `aria-label`)를 맨 앞에, 사용자는 같은 Avatar 하나만 보인다(중복 없음). 햄버거 → HM Sheet(left): 제목, 링크(44px), 그룹 제목. Radix가 focus trap·Escape·focus 복귀를 맡는다. breakpoint를 768에서 1024로 올린 이유: 메뉴 4개(항목 최소 112) 가운데 정렬은 그 이하에서 brand/profile이 너무 좁아진다. ui-viz에는 반응형 기준이 없다.
@@ -176,14 +176,14 @@ src/patterns/gnb.stories.tsx
## 15. GNB ONLY Sidebar Composition (2026-10-08) ## 15. GNB ONLY Sidebar Composition (2026-10-08)
대표 `Patterns/Layout/GNB → GNB`의 **Usage · GNB Sidebar**에서 확인한다. 직접 동작 검증은 숨김 `GNB Sidebar` Story(`patterns-layout-gnb--sidebar`)를 사용한다. 기존 `LNB → VERIFICATION ONLY · GNB + LNB`는 운영 기준으로 승격하지 않는다. 숨김 `GNB Sidebar`(`patterns-layout-gnb--sidebar`)와 `Brown Sidebar`(`patterns-layout-gnb--brown-sidebar`) Story에서 확인한다(대표 `GNB` 페이지에서는 분리됨). 기존 `LNB → VERIFICATION ONLY · GNB + LNB`는 운영 기준으로 승격하지 않는다.
구조는 **GNB Header → Sidebar + Main Content**다. Sidebar는 공개 `LNB / LNBNav / LNBGroup / LNBItem`을 그대로 사용한다. 기존 DOM/data-slot을 유지하고 브랜드·사용자 영역은 GNB만 담당한다. `LNBBrand / LNBProfile`은 기본 조합에서 생략한다. 새로운 GNBSidebar 공개 컴포넌트·메뉴 로직은 없다. 구조는 **GNB Header → Sidebar + Main Content**다. Sidebar는 공개 `LNB / LNBNav / LNBGroup / LNBItem`을 그대로 사용한다. 기존 DOM/data-slot을 유지하고 브랜드·사용자 영역은 GNB만 담당한다. `LNBBrand / LNBProfile`은 기본 조합에서 생략한다. 새로운 GNBSidebar 공개 컴포넌트·메뉴 로직은 없다.
공용 `@hm-design/ui/theme.css`의 opt-in HTML attribute 계약: 공용 `@hm-design/ui/theme.css`의 opt-in HTML attribute 계약:
- 프레임: `data-hm-gnb-layout="sidebar"`. 소비자가 높이를 지정하며 Grid가 GNB의 자연 높이와 남은 본문 높이를 배분한다. - 프레임: `data-hm-gnb-layout="sidebar"`. 소비자가 높이를 지정하며 Grid가 GNB의 자연 높이와 남은 본문 높이를 배분한다.
- 본문: 프레임의 직계 `data-hm-gnb-body`. Desktop은 기존 `--hm-lnb-width` 열 + 가용 콘텐츠 열, Mobile은 콘텐츠 한 열이다. - 본문: 프레임의 직계 `data-hm-gnb-body`. Desktop은 기존 `--hm-lnb-width` 열 + 가용 콘텐츠 열, Mobile은 콘텐츠 한 열이다. 열 폭과 항목 크기는 LNB 공식 규격(2026-11-09: 240px, 14px, 1뎁스 48px·2/3뎁스 42px)을 그대로 따르며 GNB 전용 override는 두지 않는다.
- Sidebar: 본문의 직계 `<LNB data-hm-gnb-sidebar>`. 기존 `data-slot="lnb"`는 유지한다. 이 Reference 범위에서 본문과 같은 배경, 세로 divider 한 개를 사용한다. Green은 Primary, `data-hm-color="brown"`은 기존 Brown 팔레트로 선택·Hover를 표현한다. 펼침 화살표는 유지하며 아이콘은 선택적으로 전달한다. 신규 AI GNB_ONLY에는 Sidebar를 추가하지 않는다. - Sidebar: 본문의 직계 `<LNB data-hm-gnb-sidebar>`. 기존 `data-slot="lnb"`는 유지한다. 이 Reference 범위에서 본문과 같은 배경, 세로 divider 한 개를 사용한다. Green은 Primary, `data-hm-color="brown"`은 기존 Brown 팔레트로 선택·Hover를 표현한다. 펼침 화살표는 유지하며 아이콘은 선택적으로 전달한다. 신규 AI GNB_ONLY에는 Sidebar를 추가하지 않는다.
- Main: 본문의 직계 `<main data-hm-gnb-main>`. 공용 CSS가 면·접점·스크롤을 제공하며 콘텐츠 padding은 소비자가 정한다. GNB의 상단 접점과 LNB의 측면 접점은 기존 값을 재사용한다. - Main: 본문의 직계 `<main data-hm-gnb-main>`. 공용 CSS가 면·접점·스크롤을 제공하며 콘텐츠 padding은 소비자가 정한다. GNB의 상단 접점과 LNB의 측면 접점은 기존 값을 재사용한다.
@@ -284,4 +284,4 @@ export function Example() {
사용자 메뉴와 콘텐츠 padding은 기존 GNB 사용 예제에 따라 소비자가 추가한다. Theme Source, LNB 원본, Runtime 계약, 공개 API는 변경하지 않는다. 공통 요약은 [AGENTS.md](../../AGENTS.md), 탐색 경로는 [Reference Index](../guidelines/component-pattern-reference.md)를 따른다. 사용자 메뉴와 콘텐츠 padding은 기존 GNB 사용 예제에 따라 소비자가 추가한다. Theme Source, LNB 원본, Runtime 계약, 공개 API는 변경하지 않는다. 공통 요약은 [AGENTS.md](../../AGENTS.md), 탐색 경로는 [Reference Index](../guidelines/component-pattern-reference.md)를 따른다.
검증(2026-10-08, 기존 Storybook 6024): 1440px Sidebar 272px / Green Light 2, 2·3Depth 선택·그룹 Enter/Space·Hover·선택 경로·메뉴 스크롤 확인. 768px·375px에서 GNB 56px / Sidebar 숨김 / Main 전체 폭 / 흰 Sheet 1개 / 링크 44px·14px / 선택 시 닫기·Escape·포커스 복귀 확인. 열린 Sheet에서 Desktop 복귀 시 닫힘과 선택 경로 보존 확인. 기존 GNB Overview·LNB 대표 메뉴·Dashboard Header computed style 비교 동일, 검사한 화면의 Console error 없음. 앱 TypeScript 검사 통과. 전체 프로젝트 회귀 테스트와 실기기 터치 검증은 수행하지 않았다. 검증(2026-10-08, 기존 Storybook 6024): 1440px Sidebar 272px(당시 값. 2026-11-09부터 LNB 규격 240px) / Green Light 2, 2·3Depth 선택·그룹 Enter/Space·Hover·선택 경로·메뉴 스크롤 확인. 768px·375px에서 GNB 56px / Sidebar 숨김 / Main 전체 폭 / 흰 Sheet 1개 / 링크 44px·14px / 선택 시 닫기·Escape·포커스 복귀 확인. 열린 Sheet에서 Desktop 복귀 시 닫힘과 선택 경로 보존 확인. 기존 GNB Overview·LNB 대표 메뉴·Dashboard Header computed style 비교 동일, 검사한 화면의 Console error 없음. 앱 TypeScript 검사 통과. 전체 프로젝트 회귀 테스트와 실기기 터치 검증은 수행하지 않았다.
+19 -12
View File
@@ -19,7 +19,7 @@ Implementation: current @hm-design/ui primitives / patterns (HM Collapsible + pl
|---|---| |---|---|
| 구현 상태 | 구현 완료 (2026-10-03), 3뎁스 추가 (2026-10-06) | | 구현 상태 | 구현 완료 (2026-10-03), 3뎁스 추가 (2026-10-06) |
| 기반 | HM Collapsible + 일반 요소 (새 dependency 없음) | | 기반 | HM Collapsible + 일반 요소 (새 dependency 없음) |
| 대표 Story | `Patterns/Layout/LNB` → `LNB` (verification: Third Level, Third Level Long Labels, Default, Selected, Expanded, Selected Child, Collapsed With Selection, With Logo, Long Labels, Scroll, Third Level Scroll, VERIFICATION ONLY · GNB + LNB, LNB Standalone Desktop, LNB Standalone Mobile, LNB Standalone Mobile Menu Open) | | 대표 Story | `Patterns/Layout/LNB` → `LNB` (States: Selected·Expanded·3뎁스 Selected, Variants: With Logo·Long Labels. Scroll·Standalone 반응형은 2026-11-09 대표 페이지에서 분리되어 아래 verification Story로 확인; verification: Third Level, Third Level Long Labels, Default, Selected, Expanded, Selected Child, Collapsed With Selection, With Logo, Long Labels, Scroll, Third Level Scroll, VERIFICATION ONLY · GNB + LNB, LNB Standalone Desktop, LNB Standalone Mobile, LNB Standalone Mobile Menu Open) |
| 보류 | §11 | | 보류 | §11 |
## 2. 목적 / 역할 ## 2. 목적 / 역할
@@ -81,10 +81,10 @@ Collapsible의 trigger/content는 `asChild`로 자체 `button`/`div`를 써서 C
| 항목 | 명세 | 구현 | 분류 | | 항목 | 명세 | 구현 | 분류 |
|---|---|---|---| |---|---|---|---|
| 글자 | 1·2뎁스 18/28/700, 3뎁스 18/24/500, 선택 700 | 같음 | MATCHED | | 글자 | 1·2뎁스 18/28/700, 3뎁스 18/24/500, 선택 700 | 크기만 14로 변경: 1·2뎁스 14/28/700, 3뎁스 14/24/500, 선택 700 유지 | INTENTIONAL_CHANGE (2026-11-09) |
| 3뎁스 행 | 최소 48, 상하 12, 링크 padding-left 88(2뎁스 68 + 20) | 같음 (1·2뎁스 56 유지) | MATCHED | | 3뎁스 행 | 최소 48, 상하 12, 링크 padding-left 88(2뎁스 68 + 20) | 최소 42, 상하 9, padding-left 88 유지 (1뎁스 48, 2뎁스 42) | INTENTIONAL_CHANGE (2026-11-09) |
| 3뎁스 글자 장식 | 외곽선·그림자 없음 | 같음 | MATCHED | | 3뎁스 글자 장식 | 외곽선·그림자 없음 | 같음 | MATCHED |
| 긴 이름 | 줄바꿈, 행 높이 증가 | 48 / 72 / 96 (1·2·3줄), ellipsis 없음, 가로 overflow 0 | MATCHED | | 긴 이름 | 줄바꿈, 행 높이 증가 | 3뎁스 기준 42 / 66 / 90 (1·2·3줄, 24px 줄 + 상하 9), ellipsis 없음, 가로 overflow 0. 2026-11-09 이전 값은 48 / 72 / 96 | INTENTIONAL_CHANGE (2026-11-09) |
| 선택 | Beige + `--hm-sem-action-primary`, 오른쪽 곡선 | 기존 current 규칙 그대로(폭 +1, 위·아래 오목 곡선) | MATCHED | | 선택 | Beige + `--hm-sem-action-primary`, 오른쪽 곡선 | 기존 current 규칙 그대로(폭 +1, 위·아래 오목 곡선) | MATCHED |
| 상위 경로 | 1·2뎁스 노랑, 접혀도 유지 | `current` → `--hm-yellow-accent` | MATCHED | | 상위 경로 | 1·2뎁스 노랑, 접혀도 유지 | `current` → `--hm-yellow-accent` | MATCHED |
| 연결선 | 3뎁스만, 1px, 세로선 x 68, 가로 12, 글자까지 8, 행 세로 중앙, 마지막 행 중앙에서 끝 | 행마다 background 2겹(세로 `1px × 100%`, 마지막 행 `1px × 50%`, 가로 `12 × 1` at `calc(50% - 0.5px)`). DOM·tab stop·aria 없음 | MATCHED / IMPLEMENTATION_CHANGE(명세의 "배경 레이어" 방식) | | 연결선 | 3뎁스만, 1px, 세로선 x 68, 가로 12, 글자까지 8, 행 세로 중앙, 마지막 행 중앙에서 끝 | 행마다 background 2겹(세로 `1px × 100%`, 마지막 행 `1px × 50%`, 가로 `12 × 1` at `calc(50% - 0.5px)`). DOM·tab stop·aria 없음 | MATCHED / IMPLEMENTATION_CHANGE(명세의 "배경 레이어" 방식) |
@@ -153,11 +153,11 @@ LNB Mode가 Desktop Brand/Profile과 Mobile Header/Sheet Profile을 담당한다
| 항목 | ui-viz | 최종 HM | 분류 | | 항목 | ui-viz | 최종 HM | 분류 |
|---|---|---|---| |---|---|---|---|
| 폭 | track 272, visual 290(오른쪽 18 bleed) | 열 272(`--hm-lnb-width`), 뒤로 18(`--hm-lnb-join`) 같은 색 연장 | MATCHED (연장은 `::after`) | | 폭 | track 272, visual 290(오른쪽 18 bleed) | 열 240(`--hm-lnb-width`, 2026-11-09 공식 규격 채택), 뒤로 18(`--hm-lnb-join`) 같은 색 연장 | INTENTIONAL_CHANGE (폭만 ui-viz 272와 다름, 연장은 `::after`) |
| 배경 | 90° 5-stop `#123328/#205241/#266651/#249473/#266651` 0/30/50/90/100%, 트랙 폭, 트랙 밖 `#266651` | 같음 (`--hm-green-*`) | MATCHED | | 배경 | 90° 5-stop `#123328/#205241/#266651/#249473/#266651` 0/30/50/90/100%, 트랙 폭, 트랙 밖 `#266651` | 같음 (`--hm-green-*`) | MATCHED |
| 상단 | 위 28, 브랜드 padding 0 10 22, 메뉴 위 20 | 위 28, 브랜드 inset 30(=20+10), 아래 22, 메뉴 위 20 | MATCHED | | 상단 | 위 28, 브랜드 padding 0 10 22, 메뉴 위 20 | 위 28, 브랜드 inset 30(=20+10), 아래 22, 메뉴 위 20 | MATCHED |
| 브랜드 | 로고 조건부, 위 로고·아래 서비스명, eyebrow 11/700, 이름 17 | 같음 | MATCHED | | 브랜드 | 로고 조건부, 위 로고·아래 서비스명, eyebrow 11/700, 이름 17 | 같음 | MATCHED |
| 항목 | 18/28/700, 최소 56, padding 14 12 14 18, White, 아이콘 20 opacity .5, 글자 테두리 1.5px + 그림자 `1px 2px 1px` | 같음 | MATCHED | | 항목 | 18/28/700, 최소 56, padding 14 12 14 18, White, 아이콘 20 opacity .5, 글자 테두리 1.5px + 그림자 `1px 2px 1px` | 14/28/700, 1뎁스 최소 48(padding 상하 10), 2뎁스 최소 42(상하 7). 색·아이콘·글자 테두리는 같음 | INTENTIONAL_CHANGE (2026-11-09) |
| hover | Black 10%, 콘텐츠 경계까지, 시작 쪽 radius 4 | 같음 | MATCHED | | hover | Black 10%, 콘텐츠 경계까지, 시작 쪽 radius 4 | 같음 | MATCHED |
| current | Beige + `#123328`, 왼쪽 radius 8, 오른쪽 20+1 연장, 위·아래 18 오목 곡선, 안쪽 깊이 그림자(마지막 25% fade), 글자 효과 없음 | 같음. 깊이는 span 대신 background gradient 3겹으로 근사 | MATCHED / IMPLEMENTATION_CHANGE | | current | Beige + `#123328`, 왼쪽 radius 8, 오른쪽 20+1 연장, 위·아래 18 오목 곡선, 안쪽 깊이 그림자(마지막 25% fade), 글자 효과 없음 | 같음. 깊이는 span 대신 background gradient 3겹으로 근사 | MATCHED / IMPLEMENTATION_CHANGE |
| nested(2뎁스) | 상위 = disclosure 버튼, 화살표 접힘 → 펼침 90°, 펼친 그룹 Black 8% + 1px `#266651` 선 + 왼쪽 radius 8, 하위 padding-left 68, 하단 8, 하위 선택 시 상위 노랑 `#FFC600` | 같음. HM Collapsible(Radix) 사용 | MATCHED / IMPLEMENTATION_CHANGE | | nested(2뎁스) | 상위 = disclosure 버튼, 화살표 접힘 → 펼침 90°, 펼친 그룹 Black 8% + 1px `#266651` 선 + 왼쪽 radius 8, 하위 padding-left 68, 하단 8, 하위 선택 시 상위 노랑 `#FFC600` | 같음. HM Collapsible(Radix) 사용 | MATCHED / IMPLEMENTATION_CHANGE |
@@ -175,13 +175,15 @@ LNB Mode가 Desktop Brand/Profile과 Mobile Header/Sheet Profile을 담당한다
| Token | 값 | | Token | 값 |
|---|---| |---|---|
| `--hm-lnb-width` | 272px (1023px 이하에서 0px) | | `--hm-lnb-width` | 240px (1023px 이하에서 0px) |
| `--hm-lnb-join` | 18px. 콘텐츠 둥근 모서리·bleed | | `--hm-lnb-join` | 18px. 콘텐츠 둥근 모서리·bleed |
| `--hm-lnb-background` / `-background-end` | 5-stop gradient / `#266651` | | `--hm-lnb-background` / `-background-end` | 5-stop gradient / `#266651` |
| item | min-height 56, 18/28/700, gap 12, 아이콘 20, 글자 테두리 1.5px | | item | 1뎁스 min-height 48(`--hm-lnb-item-min-height`) + padding-block 10(`--hm-lnb-item-padding-block`), 14/28/700(`--hm-lnb-item-font-size`), gap 12, 아이콘 20, 글자 테두리 1.5px |
| `--hm-lnb-subitem-*` | 2뎁스(그룹 안 직속 링크·중첩 그룹 trigger) min-height 42(`-min-height`), padding-block 7(`-padding-block`), padding-start 68 |
| `--hm-lnb-profile-min-height` | 48px. Profile 높이는 3뎁스 행과 독립이다 |
| current | `--hm-sem-bg-content` + `--hm-sem-action-primary`, radius 8 0 0 8 | | current | `--hm-sem-bg-content` + `--hm-sem-action-primary`, radius 8 0 0 8 |
| group | open face Black 8%, 선 1px `--hm-sem-palette-green-light-2`, 하위 padding-start 68, current 글자 `--hm-sem-palette-yellow-accent` | | group | open face Black 8%, 선 1px `--hm-sem-palette-green-light-2`, 하위 padding-start 68, current 글자 `--hm-sem-palette-yellow-accent` |
| `--hm-lnb-third-*` | min-height 48, padding-block 12, text padding-start 88(메뉴 행 기준), line-height 24, weight 500 / current 700, connector x `= --hm-lnb-subitem-padding-start`(68)·length 12·width 1, 모든 상태에서 `--hm-sem-palette-green-tint` 40%. 실제 link는 connector 끝부터 시작 | | `--hm-lnb-third-*` | min-height 42, padding-block 9, text padding-start 88(메뉴 행 기준), line-height 24, weight 500 / current 700, connector x `= --hm-lnb-subitem-padding-start`(68)·length 12·width 1, 모든 상태에서 `--hm-sem-palette-green-tint` 40%. 실제 link는 connector 끝부터 시작 |
새 Primitive: `--hm-yellow-accent: #ffc600`(`primitives.css`). ui-viz `color.action.accent-on-green`이며 HM 팔레트에 없어 추가했다. 그 외 새 Semantic은 없다. 새 Primitive: `--hm-yellow-accent: #ffc600`(`primitives.css`). ui-viz `color.action.accent-on-green`이며 HM 팔레트에 없어 추가했다. 그 외 새 Semantic은 없다.
@@ -212,11 +214,11 @@ viewport 높이 shell에서 모바일 Header와 main을 세로 flex로 배분할
> HISTORICAL_RECORD: 아래 날짜별 geometry·상태 측정은 당시 기록이다. 현재 링크 interaction 범위·connector 색은 §3a/§5, Profile interaction은 §7을 따른다. > HISTORICAL_RECORD: 아래 날짜별 geometry·상태 측정은 당시 기록이다. 현재 링크 interaction 범위·connector 색은 §3a/§5, Profile interaction은 §7을 따른다.
- 1280: current 항목 x 20→273, 높이 56, 18/28/700, Beige `#F5F0E5`/`#123328`, radius `8 0 0 8`, 그룹·하위 x 20→272. 하위 선택 시 상위 `rgb(255,198,0)`, 접힌 채여도 유지. 긴 이름은 줄바꿈(112px), 스크롤 영역은 메뉴만 스크롤하고 profile은 아래 22px 고정. 그룹 trigger Enter로 펼침. - 1280(당시 값. 2026-11-09부터 공식 규격은 열 240, 1뎁스 48·2/3뎁스 42, 14px): current 항목 x 20→273, 높이 56, 18/28/700, Beige `#F5F0E5`/`#123328`, radius `8 0 0 8`, 그룹·하위 x 20→272. 하위 선택 시 상위 `rgb(255,198,0)`, 접힌 채여도 유지. 긴 이름은 줄바꿈(112px), 스크롤 영역은 메뉴만 스크롤하고 profile은 아래 22px 고정. 그룹 trigger Enter로 펼침.
- 1000px: 열 `display:none`, 가로 overflow 없음. 390px은 GNB 모바일 메뉴로 대체. - 1000px: 열 `display:none`, 가로 overflow 없음. 390px은 GNB 모바일 메뉴로 대체.
- `npm run typecheck`, `npm run build` 통과. 기존 primitive Story 변화 없음. - `npm run typecheck`, `npm run build` 통과. 기존 primitive Story 변화 없음.
- 3뎁스(2026-10-06, Storybook): 위 §3a 수치를 computed style로 확인(1280 열 272, 3뎁스 x 20 → 272/선택 273, 그룹 2개 모두 x 20·폭 252, 안쪽 그룹 배경 투명·`::before` 없음). 키보드: Enter로 바깥 그룹 접힘(노랑 유지, 3뎁스·선 사라짐), Space로 다시 펼침(안쪽 그룹 열림 유지), Tab → 안쪽 그룹 Enter로 접힘. trigger는 `button` + `aria-expanded`/`aria-controls`, 선택 링크 `aria-current="page"`, focus-visible 흰 outline. 기존 Selected Child 수치 변화 없음(2뎁스 68/56/700, 그룹 하단 8, 선 1px). - 3뎁스(2026-10-06, Storybook): 위 §3a 수치를 computed style로 확인(당시 1280 열 272, 3뎁스 x 20 → 272/선택 273, 그룹 2개 모두 x 20·폭 252, 안쪽 그룹 배경 투명·`::before` 없음). 키보드: Enter로 바깥 그룹 접힘(노랑 유지, 3뎁스·선 사라짐), Space로 다시 펼침(안쪽 그룹 열림 유지), Tab → 안쪽 그룹 Enter로 접힘. trigger는 `button` + `aria-expanded`/`aria-controls`, 선택 링크 `aria-current="page"`, focus-visible 흰 outline. 기존 Selected Child 수치 변화 없음(2뎁스 68/56/700, 그룹 하단 8, 선 1px).
- 스크롤 소유(2026-10-06, Storybook): 열 = flex column, brand·profile `flex: none`, `LNBNav`만 `flex: 1 1 auto; min-height: 0; overflow-y: auto`(열 overflow 없음, 높이는 Story shell / Dashboard Layout이 결정). 메뉴 위 20을 nav margin 2 + padding 18에서 nav padding 20(`--hm-lnb-nav-padding-top`)으로 옮겨 스크롤 영역이 brand 바로 아래에서 시작(첫 항목 y 그대로). 측정: Scroll(460) nav 93.7→370 = brand bottom → profile top, client 276 / scroll 830, 최하단 마지막 항목 bottom 351.7(= nav bottom − 18); Third Level Scroll(460) client 276 / scroll 990, 3뎁스 선택 행·곡선·연결선 함께 스크롤; Dashboard Third Level 1280·1024×600 nav 163.7→510, client 346 / scroll 526, 최하단 491.7. 1023px 열 `display:none`, 가로 overflow 없음. - 스크롤 소유(2026-10-06, Storybook): 열 = flex column, brand·profile `flex: none`, `LNBNav`만 `flex: 1 1 auto; min-height: 0; overflow-y: auto`(열 overflow 없음, 높이는 Story shell / Dashboard Layout이 결정). 메뉴 위 20을 nav margin 2 + padding 18에서 nav padding 20(`--hm-lnb-nav-padding-top`)으로 옮겨 스크롤 영역이 brand 바로 아래에서 시작(첫 항목 y 그대로). 측정: Scroll(460) nav 93.7→370 = brand bottom → profile top, client 276 / scroll 830, 최하단 마지막 항목 bottom 351.7(= nav bottom − 18); Third Level Scroll(460) client 276 / scroll 990, 3뎁스 선택 행·곡선·연결선 함께 스크롤; Dashboard Third Level 1280·1024×600 nav 163.7→510, client 346 / scroll 526, 최하단 491.7. 1023px 열 `display:none`, 가로 overflow 없음.
@@ -230,6 +232,10 @@ viewport 높이 shell에서 모바일 Header와 main을 세로 flex로 배분할
Collapsible, Navigation Menu 등 기존 primitive 코드·theme CSS는 수정하지 않았다. `primitives.css`에 `--hm-yellow-accent` 한 줄만 추가했다. Collapsible, Navigation Menu 등 기존 primitive 코드·theme CSS는 수정하지 않았다. `primitives.css`에 `--hm-yellow-accent` 한 줄만 추가했다.
**공식 기본 규격 (2026-11-09):** PC 열 240px, 메뉴 14px, 행 높이 1뎁스 48px·2/3뎁스 42px를 `packages/ui/src/theme/lnb.css`의 공식 기본값으로 채택했다. 이전 값(272px, 18px, 56px / 3뎁스 48px)은 위 날짜별 검증 기록에만 남는다. 모바일 규격(`--hm-lnb-mobile-*`)과 `DashboardSidebar`(자체 `--hm-dashboard-sidebar-width` 272px)는 변경하지 않았다. GNB ONLY 내부 Sidebar는 LNB 부품과 `--hm-lnb-width`를 재사용하므로 같은 규격(240px / 14px / 48px / 42px)을 따른다. 소비 프로젝트는 크기를 Storybook 화면이 아니라 `--hm-lnb-*` 토큰에서 읽어야 하며, 새 CSS 변수는 `--hm-lnb-item-padding-block`, `--hm-lnb-subitem-min-height`, `--hm-lnb-subitem-padding-block`, `--hm-lnb-profile-min-height` 4개다.
**Storybook 전용 표시(`src/patterns/lnb.stories.css`):** `Patterns/Layout/LNB` 대표 Story만 `.hm-lnb-story` 범위 안에서 최소 너비 212px, 브랜드 아래 padding 8px, Profile 글자(14/12/700), 구분선을 덮어쓴다. 크기 규격은 더 이상 덮어쓰지 않는다.
## 10. 남은 차이 ## 10. 남은 차이
| 항목 | 분류 | | 항목 | 분류 |
@@ -241,7 +247,7 @@ Collapsible, Navigation Menu 등 기존 primitive 코드·theme CSS는 수정하
| 실기기, 1024~1280 사이 시각 | 미확인 | | 실기기, 1024~1280 사이 시각 | 미확인 |
| 4뎁스 이상 | 명세 없음. 3뎁스 규칙만 정의(더 깊이 중첩하면 3뎁스 모양이 반복된다) | | 4뎁스 이상 | 명세 없음. 3뎁스 규칙만 정의(더 깊이 중첩하면 3뎁스 모양이 반복된다) |
| 선택 경로 전체 연결선을 선택색으로 칠할지 | 현재 모든 connector는 Green Tint 40%를 유지한다(§3a/§5). 선택은 Beige 면 + Primary 글자 + 700 weight로 표현하며, 경로 전체 선의 선택색은 별도 결정 전까지 적용하지 않는다 | | 선택 경로 전체 연결선을 선택색으로 칠할지 | 현재 모든 connector는 Green Tint 40%를 유지한다(§3a/§5). 선택은 Beige 면 + Primary 글자 + 700 weight로 표현하며, 경로 전체 선의 선택색은 별도 결정 전까지 적용하지 않는다 |
| LNB 모바일 Sheet 그룹 접기·상위 current 표시 | 현재 링크 목록 방식. Desktop 18px / Mobile 14px 유지 | | LNB 모바일 Sheet 그룹 접기·상위 current 표시 | 현재 링크 목록 방식. Desktop 14px(2026-11-09, 이전 18px) / Mobile 14px 유지 |
| LNB Mobile Header 공개 API | 구현 완료. GNB/Dashboard 의존성 없음 | | LNB Mobile Header 공개 API | 구현 완료. GNB/Dashboard 의존성 없음 |
## 11. 관련 파일 ## 11. 관련 파일
@@ -251,4 +257,5 @@ packages/ui/src/patterns/lnb/{lnb.tsx,lnb-mobile.tsx,index.ts}
packages/ui/src/theme/lnb.css packages/ui/src/theme/lnb.css
packages/ui/src/theme/primitives.css (--hm-yellow-accent) packages/ui/src/theme/primitives.css (--hm-yellow-accent)
src/patterns/lnb.stories.tsx src/patterns/lnb.stories.tsx
src/patterns/lnb.stories.css (Storybook 전용 오버라이드)
``` ```
+1 -1
View File
@@ -51,7 +51,7 @@ Progress는 Indeterminate 표현을 여전히 갖지 않는다(`progress.md` §1
| 텍스트 | 14/500/-0.02em, white 60%, 끝에 정적 "..." | 동일, 폰트는 `--hm-font-family-base` | MATCHED | | 텍스트 | 14/500/-0.02em, white 60%, 끝에 정적 "..." | 동일, 폰트는 `--hm-font-family-base` | MATCHED |
| Inline | padding 36/24, radius 6, inset shadow 24px .45, gap 12, 가운데 정렬 | 동일 (실측 높이 149, ui_viz smoke 148은 폰트 line box 차이) | MATCHED | | Inline | padding 36/24, radius 6, inset shadow 24px .45, gap 12, 가운데 정렬 | 동일 (실측 높이 149, ui_viz smoke 148은 폰트 line box 차이) | MATCHED |
| Fullscreen | fixed inset 0, assembly는 block·gap 0, 스피너는 묶음 leading edge(텍스트가 44px보다 넓으면 두 중심축이 어긋남), z-index 9999 | fixed inset 0, inline과 같은 column·gap 12로 묶음 전체를 viewport 중앙(스피너·텍스트 중심축 일치) | INTENTIONAL_CHANGE (2026-10-06 사용자 지시: 스피너가 왼쪽으로 치우쳐 보임) | | Fullscreen | fixed inset 0, assembly는 block·gap 0, 스피너는 묶음 leading edge(텍스트가 44px보다 넓으면 두 중심축이 어긋남), z-index 9999 | fixed inset 0, inline과 같은 column·gap 12로 묶음 전체를 viewport 중앙(스피너·텍스트 중심축 일치) | INTENTIONAL_CHANGE (2026-10-06 사용자 지시: 스피너가 왼쪽으로 치우쳐 보임) |
| 면 색 | `#0a3322` (Dashboard-local) | `--hm-sem-action-primary` (Default `#123328`) | INTENTIONAL_CHANGE (HM Primitive 재사용, 새 색 만들지 않음) | | 면 색 | `#0a3322` (Dashboard-local) | `--hm-sem-action-primary` (Default `#164434`) | INTENTIONAL_CHANGE (HM Primitive 재사용, 새 색 만들지 않음) |
| 폰트 | Inter → Noto Sans KR | HM 기본 폰트 | INTENTIONAL_CHANGE | | 폰트 | Inter → Noto Sans KR | HM 기본 폰트 | INTENTIONAL_CHANGE |
| 막대 끝 | radius 1.5 → 렌더에서 pill | `9999px` | MATCHED | | 막대 끝 | radius 1.5 → 렌더에서 pill | `9999px` | MATCHED |
| 숨김/표시 fade 0.3s | 있음 | 없음 | FOLLOW_UP (소비자 lifecycle) | | 숨김/표시 fade 0.3s | 있음 | 없음 | FOLLOW_UP (소비자 lifecycle) |
+1 -1
View File
@@ -79,7 +79,7 @@ Breadcrumb는 HM Breadcrumb 그대로(14px, 링크). ui-viz는 12px 정적 텍
페이지 content 면에 바로 놓는 Tabs는 PageHeader 다음에 기본 Primary(`panelSurface="content"`)를 사용한다. PageHeader의 제목→설명 **8px**, 아래 첫 콘텐츠까지 **20px**을 유지한다. 탭을 감싸는 새 White Card·border·padding은 필요하지 않으며 내부 `TabsContent`는 `px-0 py-6`로 조합할 수 있다. [Tabs의 표면·여백 소유권](../components/tabs.md#16-publisher--developer-조합-안내-2026-10-09)을 따른다. 페이지 content 면에 바로 놓는 Tabs는 PageHeader 다음에 기본 Primary(`panelSurface="content"`)를 사용한다. PageHeader의 제목→설명 **8px**, 아래 첫 콘텐츠까지 **20px**을 유지한다. 탭을 감싸는 새 White Card·border·padding은 필요하지 않으며 내부 `TabsContent`는 `px-0 py-6`로 조합할 수 있다. [Tabs의 표면·여백 소유권](../components/tabs.md#16-publisher--developer-조합-안내-2026-10-09)을 따른다.
2026-10-09 실제 반영: 대표 Story의 Overview에서 기존 PageHeader 바로 뒤에 `내 업무 / 완료 업무` Tabs와 공개 Table을 조합한다. 선택 탭의 면은 [Primary 상단 White alpha 0.8](../components/tabs.md#163-d-02-상단-흰색-80-실제-반영) → content로 이어지고, 탭 전체 opacity는 1이다. PageHeader Component·Typography·8px/20px Token과 기존 단일 Brand Action은 유지한다. 별도 Pattern API를 추가하지 않는다. 2026-10-09 실제 반영(2026-11-09: 대표 Story는 Variants의 Basic·With Breadcrumb만 두고, 이 Tabs/Table 조합은 숨김 verification Story `Full Page`(`patterns-layout-page-header--full-page`)로 분리): 기존 PageHeader 바로 뒤에 `내 업무 / 완료 업무` Tabs와 공개 Table을 조합한다. 선택 탭의 면은 [Primary 상단 White alpha 0.8](../components/tabs.md#163-d-02-상단-흰색-80-실제-반영) → content로 이어지고, 탭 전체 opacity는 1이다. PageHeader Component·Typography·8px/20px Token과 기존 단일 Brand Action은 유지한다. 별도 Pattern API를 추가하지 않는다.
이때 TabsContent의 상하 24px을 PageHeader 하단 20px에 더해 탭 바 앞 간격으로 사용하지 않는다. Card 안의 제목은 CardTitle 규격(20/28/600), 페이지 제목은 PageHeaderTitle 규격(28/36/700)을 사용한다. CardDescription과 PageHeaderDescription은 서로 다른 계약이다. 비교 이미지의 48px 간격이나 Card 전용 Token을 페이지 전체에 적용하지 않는다. 이때 TabsContent의 상하 24px을 PageHeader 하단 20px에 더해 탭 바 앞 간격으로 사용하지 않는다. Card 안의 제목은 CardTitle 규격(20/28/600), 페이지 제목은 PageHeaderTitle 규격(28/36/700)을 사용한다. CardDescription과 PageHeaderDescription은 서로 다른 계약이다. 비교 이미지의 48px 간격이나 Card 전용 Token을 페이지 전체에 적용하지 않는다.
+1
View File
@@ -31,6 +31,7 @@
- 요청이 모드를 말하지 않으면 화면 목적(서비스 상단 메뉴 = GNB, 계층 업무 메뉴 = LNB, 운영 현황 = DASHBOARD)으로 고르고 **선택 근거를 보고**합니다. 임의로 Sidebar를 추가하지 않습니다. - 요청이 모드를 말하지 않으면 화면 목적(서비스 상단 메뉴 = GNB, 계층 업무 메뉴 = LNB, 운영 현황 = DASHBOARD)으로 고르고 **선택 근거를 보고**합니다. 임의로 Sidebar를 추가하지 않습니다.
- 혼합 요청(GNB + LNB 등)은 정책 충돌로 안내하고 하나를 고르게 합니다. Drawer·Hamburger 등 대체 메뉴를 추측해 만들지 않습니다. - 혼합 요청(GNB + LNB 등)은 정책 충돌로 안내하고 하나를 고르게 합니다. Drawer·Hamburger 등 대체 메뉴를 추측해 만들지 않습니다.
- Breadcrumb·FilterBar·사용자 Dropdown은 Navigation 모드가 아닙니다. - Breadcrumb·FilterBar·사용자 Dropdown은 Navigation 모드가 아닙니다.
- **LNB 크기의 기준은 패키지 토큰입니다.** `@hm-design/ui`의 `--hm-lnb-*`가 정의한 값(Desktop 열 240px, 메뉴 14/28/700, 1뎁스 행 48px, 2·3뎁스 행 42px, Profile 48px, 모바일 Header 56px·링크 44px)을 그대로 씁니다. Storybook 화면에서 눈으로 잰 값이나 과거 예제의 272px·18px·56px를 새로 정하지 않고, `--hm-lnb-width` 등을 소비 CSS에서 다시 정의하지 않습니다. 레이아웃 열은 `var(--hm-lnb-width)`로 읽습니다.
```tsx ```tsx
// LNB ONLY 골격 // LNB ONLY 골격
+3 -2
View File
@@ -11,8 +11,8 @@
| 역할 | 값 | 변수 | | 역할 | 값 | 변수 |
|---|---|---| |---|---|---|
| Green(주색) | `#123328` | `--hm-sem-palette-green-default` | | Green(주색) | `#164434` | `--hm-sem-palette-green-default` |
| Green 진한 글자 | `#0A221D` | `--hm-sem-palette-green-dark-1` | | Green 진한 글자 | `#11372D` | `--hm-sem-palette-green-dark-1` |
| Green Focus·Active | `#266651` | `--hm-sem-palette-green-light-2` | | Green Focus·Active | `#266651` | `--hm-sem-palette-green-light-2` |
| Green Tint(강조 면) | `#BDDFD4` | `--hm-sem-palette-green-tint` | | Green Tint(강조 면) | `#BDDFD4` | `--hm-sem-palette-green-tint` |
| Beige(콘텐츠 면) | `#F5F0E5` | `--hm-sem-palette-beige-light` | | Beige(콘텐츠 면) | `#F5F0E5` | `--hm-sem-palette-beige-light` |
@@ -68,6 +68,7 @@ Brown·Orange 계열 팔레트(`--hm-sem-palette-brown-*`, `--hm-sem-palette-ora
| Select Trigger 높이 | medium 34 · large 44(기본) | — | | Select Trigger 높이 | medium 34 · large 44(기본) | — |
| Control radius | 4px | `--hm-radius-control-md` → `--hm-radius-button`, `--hm-radius-input`, `--hm-radius-select`, `--hm-radius-container` | | Control radius | 4px | `--hm-radius-control-md` → `--hm-radius-button`, `--hm-radius-input`, `--hm-radius-select`, `--hm-radius-container` |
| Card | border 1px, padding 24px, radius 4px, 그림자 없음 | `--hm-card-*` | | Card | border 1px, padding 24px, radius 4px, 그림자 없음 | `--hm-card-*` |
| LNB(Desktop) | 열 240px, 메뉴 14/28/700, 1뎁스 행 48 · 2·3뎁스 행 42, Profile 48, 콘텐츠 접점 18. 모바일 Header 56 · 링크 44 | `--hm-lnb-width`, `--hm-lnb-item-*`, `--hm-lnb-subitem-*`, `--hm-lnb-third-*`, `--hm-lnb-profile-min-height`, `--hm-lnb-join` |
| Dialog | radius 8px, padding 24px, Header 아래 16px, Footer 위 16px, 버튼 간격 8px, 그림자 `0 0 12px` 검정 20% | `--hm-dialog-*` | | Dialog | radius 8px, padding 24px, Header 아래 16px, Footer 위 16px, 버튼 간격 8px, 그림자 `0 0 12px` 검정 20% | `--hm-dialog-*` |
## 5. 상태 ## 5. 상태
@@ -3,24 +3,24 @@
export const runtimeThemeDefaults = { export const runtimeThemeDefaults = {
"--hm-sem-bg-content": "#f5f0e5", "--hm-sem-bg-content": "#f5f0e5",
"--hm-sem-bg-surface": "#ffffff", "--hm-sem-bg-surface": "#ffffff",
"--hm-sem-fg-primary": "#0a221d", "--hm-sem-fg-primary": "#11372d",
"--hm-sem-fg-secondary": "#6e6759", "--hm-sem-fg-secondary": "#6e6759",
"--hm-sem-fg-placeholder": "#999999", "--hm-sem-fg-placeholder": "#999999",
"--hm-sem-action-primary": "#123328", "--hm-sem-action-primary": "#164434",
"--hm-sem-action-cancel": "#6e6759", "--hm-sem-action-cancel": "#6e6759",
"--hm-sem-action-destructive": "#d32f2f", "--hm-sem-action-destructive": "#d32f2f",
"--hm-sem-status-error": "#b3261e", "--hm-sem-status-error": "#b3261e",
"--hm-sem-stroke-default": "#9a9284", "--hm-sem-stroke-default": "#9a9284",
"--hm-sem-stroke-input-default": "#dedbd2", "--hm-sem-stroke-input-default": "#dedbd2",
"--hm-sem-stroke-input-focus": "#266651", "--hm-sem-stroke-input-focus": "#266651",
"--hm-sem-brand-bg-stop-0": "#0a221d", "--hm-sem-brand-bg-stop-0": "#11372d",
"--hm-sem-brand-bg-stop-12": "#071e18", "--hm-sem-brand-bg-stop-12": "#0b2b23",
"--hm-sem-brand-bg-stop-42": "#123328", "--hm-sem-brand-bg-stop-42": "#164434",
"--hm-sem-brand-bg-stop-78": "#266651", "--hm-sem-brand-bg-stop-78": "#266651",
"--hm-sem-brand-bg-stop-100": "#205241", "--hm-sem-brand-bg-stop-100": "#205241",
"--hm-sem-brown-bg-stop-0": "#352d1d", "--hm-sem-brown-bg-stop-0": "#403726",
"--hm-sem-brown-bg-stop-12": "#2f281c", "--hm-sem-brown-bg-stop-12": "#2f281c",
"--hm-sem-brown-bg-stop-42": "#3e3523", "--hm-sem-brown-bg-stop-42": "#4e422d",
"--hm-sem-brown-bg-stop-78": "#897445", "--hm-sem-brown-bg-stop-78": "#897445",
"--hm-sem-brown-bg-stop-100": "#604f32", "--hm-sem-brown-bg-stop-100": "#604f32",
"--hm-sem-orange-bg-stop-0": "#bc4c00", "--hm-sem-orange-bg-stop-0": "#bc4c00",
@@ -32,7 +32,7 @@ export const runtimeThemeDefaults = {
"--hm-sem-action-surface-stop-30": "#165c47", "--hm-sem-action-surface-stop-30": "#165c47",
"--hm-sem-action-surface-stop-65": "#114838", "--hm-sem-action-surface-stop-65": "#114838",
"--hm-sem-action-surface-stop-100": "#08241c", "--hm-sem-action-surface-stop-100": "#08241c",
"--hm-sem-action-border-stop-0": "#0a221d", "--hm-sem-action-border-stop-0": "#11372d",
"--hm-sem-action-border-stop-50": "#34daaa", "--hm-sem-action-border-stop-50": "#34daaa",
"--hm-sem-action-border-stop-100": "#08241c", "--hm-sem-action-border-stop-100": "#08241c",
"--hm-radius-input": "4px", "--hm-radius-input": "4px",
@@ -47,13 +47,13 @@ export const runtimeThemeDefaults = {
"--hm-sem-palette-green-light-3": "#249473", "--hm-sem-palette-green-light-3": "#249473",
"--hm-sem-palette-green-light-2": "#266651", "--hm-sem-palette-green-light-2": "#266651",
"--hm-sem-palette-green-light-1": "#205241", "--hm-sem-palette-green-light-1": "#205241",
"--hm-sem-palette-green-default": "#123328", "--hm-sem-palette-green-default": "#164434",
"--hm-sem-palette-green-dark-1": "#0a221d", "--hm-sem-palette-green-dark-1": "#11372d",
"--hm-sem-palette-green-dark-2": "#071e18", "--hm-sem-palette-green-dark-2": "#0b2b23",
"--hm-sem-palette-brown-light-2": "#897445", "--hm-sem-palette-brown-light-2": "#897445",
"--hm-sem-palette-brown-light-1": "#604f32", "--hm-sem-palette-brown-light-1": "#604f32",
"--hm-sem-palette-brown-default": "#3e3523", "--hm-sem-palette-brown-default": "#4e422d",
"--hm-sem-palette-brown-dark-1": "#352d1d", "--hm-sem-palette-brown-dark-1": "#403726",
"--hm-sem-palette-brown-light-4": "#ecd4a2", "--hm-sem-palette-brown-light-4": "#ecd4a2",
"--hm-sem-palette-orange-light-4": "#feba88", "--hm-sem-palette-orange-light-4": "#feba88",
"--hm-sem-palette-orange-default": "#cc5200", "--hm-sem-palette-orange-default": "#cc5200",
@@ -85,10 +85,10 @@ export const runtimeThemeDefaults = {
"--hm-sem-palette-gray": "#767676", "--hm-sem-palette-gray": "#767676",
"--hm-sem-palette-gray-dark": "#5a5a5a", "--hm-sem-palette-gray-dark": "#5a5a5a",
"--hm-sem-palette-indicator": "#2d2d2d", "--hm-sem-palette-indicator": "#2d2d2d",
"--hm-sem-palette-green-dark-3": "#020a07", "--hm-sem-palette-green-dark-3": "#030e0a",
"--hm-sem-palette-brown-light-3": "#c1a770", "--hm-sem-palette-brown-light-3": "#c1a770",
"--hm-sem-palette-brown-dark-2": "#2f281c", "--hm-sem-palette-brown-dark-2": "#2f281c",
"--hm-sem-palette-brown-dark-3": "#1d1810", "--hm-sem-palette-brown-dark-3": "#0d0b07",
"--hm-sem-palette-orange-light-3": "#ec8f53", "--hm-sem-palette-orange-light-3": "#ec8f53",
"--hm-sem-palette-orange-light-2": "#f87725", "--hm-sem-palette-orange-light-2": "#f87725",
"--hm-sem-palette-orange-light-1": "#eb5f00", "--hm-sem-palette-orange-light-1": "#eb5f00",
+53 -13
View File
@@ -4,7 +4,7 @@
Black 10% to the same edge; an open group is a Black 8% face with a 1px green line. Spacing values are pattern-local. */ Black 10% to the same edge; an open group is a Black 8% face with a 1px green line. Spacing values are pattern-local. */
:root, [data-hm-theme] { :root, [data-hm-theme] {
/* Exposed for the layout: grid column width and the surface that continues behind the content's rounded corners. */ /* Exposed for the layout: grid column width and the surface that continues behind the content's rounded corners. */
--hm-lnb-width: 272px; --hm-lnb-width: 240px;
--hm-lnb-join: 18px; --hm-lnb-join: 18px;
--hm-lnb-background: linear-gradient(90deg, var(--hm-sem-action-primary) 0%, var(--hm-sem-palette-green-light-1) 30%, var(--hm-sem-palette-green-light-2) 50%, var(--hm-sem-palette-green-light-3) 90%, var(--hm-sem-palette-green-light-2) 100%); --hm-lnb-background: linear-gradient(90deg, var(--hm-sem-action-primary) 0%, var(--hm-sem-palette-green-light-1) 30%, var(--hm-sem-palette-green-light-2) 50%, var(--hm-sem-palette-green-light-3) 90%, var(--hm-sem-palette-green-light-2) 100%);
--hm-lnb-background-end: var(--hm-sem-palette-green-light-2); --hm-lnb-background-end: var(--hm-sem-palette-green-light-2);
@@ -29,10 +29,12 @@
--hm-lnb-brand-name-size: 17px; --hm-lnb-brand-name-size: 17px;
--hm-lnb-brand-text-gap: 5px; --hm-lnb-brand-text-gap: 5px;
--hm-lnb-item-min-height: 56px; --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; --hm-lnb-item-padding-start: 18px;
--hm-lnb-item-gap: 12px; --hm-lnb-item-gap: 12px;
--hm-lnb-item-font-size: 18px; --hm-lnb-item-font-size: 14px;
--hm-lnb-item-line-height: 28px; --hm-lnb-item-line-height: 28px;
--hm-lnb-item-font-weight: 700; --hm-lnb-item-font-weight: 700;
--hm-lnb-item-radius: 4px 0 0 4px; --hm-lnb-item-radius: 4px 0 0 4px;
@@ -46,11 +48,38 @@
--hm-lnb-current-background: var(--hm-sem-bg-content); --hm-lnb-current-background: var(--hm-sem-bg-content);
--hm-lnb-current-text: var(--hm-sem-action-primary); --hm-lnb-current-text: var(--hm-sem-action-primary);
--hm-lnb-current-radius: 8px 0 0 8px; --hm-lnb-current-radius: 8px 0 0 8px;
/* Inset depth of the current face: left edge, top, bottom; the last quarter fades so the content side stays open. */ /* Inset depth of the current face (left, top, bottom), matched to the Storybook Manager's selected item: inset box-shadows
6px 0 8px -5px Black 30%, 0 3px 5px -3px Black 20% and 0 -2px 4px -3px Black 20%, faded out over the last quarter. A background
cannot hold a box-shadow and ::before / ::after already draw the concave corners, so the blurred falloff is sampled as gradient
stops (left 10px, top 5px, bottom 4px), and the first layer repaints the face color from 75% to 100% so the depth fades toward
the content side. */
--hm-lnb-current-depth: --hm-lnb-current-depth:
linear-gradient(to right, color-mix(in srgb, var(--hm-sem-palette-black) 30%, transparent), transparent 6px) 0 0 / 75% 100% no-repeat, linear-gradient(to right, transparent, var(--hm-lnb-current-background)) 100% 0 / 25% 100% no-repeat,
linear-gradient(to bottom, color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent), transparent 3px) 0 0 / 75% 100% no-repeat, linear-gradient(to right,
linear-gradient(to top, color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent), transparent 3px) 0 0 / 75% 100% no-repeat; color-mix(in srgb, var(--hm-sem-palette-black) 20%, transparent) 0,
color-mix(in srgb, var(--hm-sem-palette-black) 15.7%, transparent) 1px,
color-mix(in srgb, var(--hm-sem-palette-black) 12.2%, transparent) 2px,
color-mix(in srgb, var(--hm-sem-palette-black) 9.5%, transparent) 3px,
color-mix(in srgb, var(--hm-sem-palette-black) 7.1%, transparent) 4px,
color-mix(in srgb, var(--hm-sem-palette-black) 5%, transparent) 5px,
color-mix(in srgb, var(--hm-sem-palette-black) 3.3%, transparent) 6px,
color-mix(in srgb, var(--hm-sem-palette-black) 2.2%, transparent) 7px,
color-mix(in srgb, var(--hm-sem-palette-black) 1.4%, transparent) 8px,
color-mix(in srgb, var(--hm-sem-palette-black) 0.8%, transparent) 9px,
transparent 10px),
linear-gradient(to bottom,
color-mix(in srgb, var(--hm-sem-palette-black) 12%, transparent) 0,
color-mix(in srgb, var(--hm-sem-palette-black) 8.5%, transparent) 1px,
color-mix(in srgb, var(--hm-sem-palette-black) 5%, transparent) 2px,
color-mix(in srgb, var(--hm-sem-palette-black) 2.3%, transparent) 3px,
color-mix(in srgb, var(--hm-sem-palette-black) 0.8%, transparent) 4px,
transparent 5px),
linear-gradient(to top,
color-mix(in srgb, var(--hm-sem-palette-black) 9%, transparent) 0,
color-mix(in srgb, var(--hm-sem-palette-black) 5.1%, transparent) 1px,
color-mix(in srgb, var(--hm-sem-palette-black) 2.4%, transparent) 2px,
color-mix(in srgb, var(--hm-sem-palette-black) 0.7%, transparent) 3px,
transparent 4px);
--hm-lnb-group-open-background: color-mix(in srgb, var(--hm-sem-palette-black) 8%, transparent); --hm-lnb-group-open-background: color-mix(in srgb, var(--hm-sem-palette-black) 8%, transparent);
--hm-lnb-group-open-line: 1px solid var(--hm-sem-palette-green-light-2); --hm-lnb-group-open-line: 1px solid var(--hm-sem-palette-green-light-2);
@@ -58,14 +87,17 @@
--hm-lnb-group-current-text: var(--hm-sem-palette-yellow-accent); --hm-lnb-group-current-text: var(--hm-sem-palette-yellow-accent);
--hm-lnb-group-arrow-size: 18px; --hm-lnb-group-arrow-size: 18px;
--hm-lnb-subitem-padding-start: 68px; --hm-lnb-subitem-padding-start: 68px;
/* 2-depth row (links directly in a group, nested group triggers): 28px line + 2 x 7px = 42px. */
--hm-lnb-subitem-min-height: 42px;
--hm-lnb-subitem-padding-block: 7px;
/* Align the 2-depth link surface with the 1-depth group text; retain its existing text inset. */ /* Align the 2-depth link surface with the 1-depth group text; retain its existing text inset. */
--hm-lnb-subitem-surface-inset: calc(var(--hm-lnb-item-padding-start) + var(--hm-lnb-group-arrow-size) + var(--hm-lnb-item-gap)); --hm-lnb-subitem-surface-inset: calc(var(--hm-lnb-item-padding-start) + var(--hm-lnb-group-arrow-size) + var(--hm-lnb-item-gap));
--hm-lnb-group-padding-bottom: 8px; --hm-lnb-group-padding-bottom: 8px;
/* 3-depth: the items of an `LNBGroup` nested in a group. Same 18px; the hierarchy is the extra 20px indent and the connector /* 3-depth: the items of an `LNBGroup` nested in a group. Same font size as the other depths; the hierarchy is the extra 20px indent and the connector
(a vertical line at the 2-depth text start, a 12px branch to each row's middle, 8px to the text; it ends at the last row). */ (a vertical line at the 2-depth text start, a 12px branch to each row's middle, 8px to the text; it ends at the last row). 42px = 24px line + 9px padding x2. */
--hm-lnb-third-min-height: 48px; --hm-lnb-third-min-height: 42px;
--hm-lnb-third-padding-block: 12px; --hm-lnb-third-padding-block: 9px;
--hm-lnb-third-padding-start: 88px; --hm-lnb-third-padding-start: 88px;
--hm-lnb-third-line-height: 24px; --hm-lnb-third-line-height: 24px;
--hm-lnb-third-font-weight: 500; --hm-lnb-third-font-weight: 500;
@@ -79,6 +111,7 @@
--hm-lnb-profile-padding-x: 30px; --hm-lnb-profile-padding-x: 30px;
--hm-lnb-profile-padding-top: 22px; --hm-lnb-profile-padding-top: 22px;
--hm-lnb-profile-gap: 12px; --hm-lnb-profile-gap: 12px;
--hm-lnb-profile-min-height: 48px;
--hm-lnb-profile-name-size: 16px; --hm-lnb-profile-name-size: 16px;
--hm-lnb-profile-description-size: 12px; --hm-lnb-profile-description-size: 12px;
} }
@@ -150,7 +183,7 @@
max-width: calc(100% - 2 * (var(--hm-lnb-profile-padding-x) - 8px)); max-width: calc(100% - 2 * (var(--hm-lnb-profile-padding-x) - 8px));
margin-top: auto; margin-top: auto;
margin-inline: calc(var(--hm-lnb-profile-padding-x) - 8px); margin-inline: calc(var(--hm-lnb-profile-padding-x) - 8px);
min-height: var(--hm-lnb-third-min-height); min-height: var(--hm-lnb-profile-min-height);
padding: 0 8px; padding: 0 8px;
border: 0; border: 0;
background: transparent; background: transparent;
@@ -211,7 +244,7 @@
gap: var(--hm-lnb-item-gap); gap: var(--hm-lnb-item-gap);
width: calc(100% - 1px); width: calc(100% - 1px);
min-height: var(--hm-lnb-item-min-height); min-height: var(--hm-lnb-item-min-height);
padding: 14px calc(var(--hm-lnb-inset-end) + 12px) 14px var(--hm-lnb-item-padding-start); padding: var(--hm-lnb-item-padding-block) calc(var(--hm-lnb-inset-end) + 12px) var(--hm-lnb-item-padding-block) var(--hm-lnb-item-padding-start);
border: 0; border: 0;
border-radius: var(--hm-lnb-item-radius); border-radius: var(--hm-lnb-item-radius);
background: transparent; background: transparent;
@@ -329,6 +362,13 @@
[data-slot="lnb-group-content"] [data-slot="lnb-item"]:not([aria-current="page"]):hover { [data-slot="lnb-group-content"] [data-slot="lnb-item"]:not([aria-current="page"]):hover {
border-radius: var(--hm-lnb-item-radius); border-radius: var(--hm-lnb-item-radius);
} }
/* 2-depth rows (links directly in a group, nested group triggers): 42px = 28px line + 2 x 7px. */
[data-slot="lnb-group-content"] > [data-slot="lnb-item"],
[data-slot="lnb-group-content"] [data-slot="lnb-group-trigger"] {
min-height: var(--hm-lnb-subitem-min-height);
padding-top: var(--hm-lnb-subitem-padding-block);
padding-bottom: var(--hm-lnb-subitem-padding-block);
}
[data-slot="lnb-group-content"] [data-slot="lnb-item"][aria-current="page"] { [data-slot="lnb-group-content"] [data-slot="lnb-item"][aria-current="page"] {
width: calc(100% + 1px); width: calc(100% + 1px);
margin-right: -1px; margin-right: -1px;
+7 -7
View File
@@ -5,14 +5,14 @@
--hm-green-light-3: #249473; --hm-green-light-3: #249473;
--hm-green-light-2: #266651; --hm-green-light-2: #266651;
--hm-green-light-1: #205241; --hm-green-light-1: #205241;
--hm-green-default: #123328; --hm-green-default: #164434;
--hm-green-dark-1: #0a221d; --hm-green-dark-1: #11372d;
--hm-green-dark-2: #071e18; --hm-green-dark-2: #0b2b23;
--hm-brown-light-2: #897445; --hm-brown-light-2: #897445;
--hm-brown-light-1: #604f32; --hm-brown-light-1: #604f32;
--hm-brown-default: #3e3523; --hm-brown-default: #4e422d;
--hm-brown-dark-1: #352d1d; --hm-brown-dark-1: #403726;
--hm-brown-light-4: #ecd4a2; --hm-brown-light-4: #ecd4a2;
@@ -47,10 +47,10 @@
/* Confirmed brand palette additions (ui_viz Color 4.1; Orange Dark 1/2 confirmed by user 2026-10-11). */ /* Confirmed brand palette additions (ui_viz Color 4.1; Orange Dark 1/2 confirmed by user 2026-10-11). */
:root { :root {
--hm-green-dark-3: #020a07; --hm-green-dark-3: #030e0a;
--hm-brown-light-3: #c1a770; --hm-brown-light-3: #c1a770;
--hm-brown-dark-2: #2f281c; --hm-brown-dark-2: #2f281c;
--hm-brown-dark-3: #1d1810; --hm-brown-dark-3: #0d0b07;
--hm-orange-light-3: #ec8f53; --hm-orange-light-3: #ec8f53;
--hm-orange-light-2: #f87725; --hm-orange-light-2: #f87725;
--hm-orange-light-1: #eb5f00; --hm-orange-light-1: #eb5f00;
+3 -3
View File
@@ -62,11 +62,11 @@ try {
const page = await browser.newPage(); const page = await browser.newPage();
await page.goto(`${base}/storybook/iframe.html?id=theme-designer-playground--editor&viewMode=story`, { waitUntil: 'networkidle' }); await page.goto(`${base}/storybook/iframe.html?id=theme-designer-playground--editor&viewMode=story`, { waitUntil: 'networkidle' });
await page.getByRole('button', { name: 'Load Service Example JSON', exact: true }).click(); await page.getByRole('button', { name: 'Load Service Example JSON', exact: true }).click();
await page.waitForFunction(() => document.querySelector('[aria-label="Primary / Brand HEX"]')?.value !== '#123328'); await page.waitForFunction(() => document.querySelector('[aria-label="Primary / Brand HEX"]')?.value !== '#164434');
const changed = await page.getByRole('textbox', { name: 'Primary / Brand HEX', exact: true }).inputValue(); const changed = await page.getByRole('textbox', { name: 'Primary / Brand HEX', exact: true }).inputValue();
await page.getByRole('button', { name: 'Load Default JSON', exact: true }).click(); await page.getByRole('button', { name: 'Load Default JSON', exact: true }).click();
await page.waitForFunction(() => document.querySelector('[aria-label="Primary / Brand HEX"]')?.value === '#123328'); await page.waitForFunction(() => document.querySelector('[aria-label="Primary / Brand HEX"]')?.value === '#164434');
await page.close(); return { servicePrimary: changed, defaultPrimary: '#123328' }; await page.close(); return { servicePrimary: changed, defaultPrimary: '#164434' };
}); });
await task('RUNTIME_THEME_KEYBOARD', async () => { await task('RUNTIME_THEME_KEYBOARD', async () => {
const page = await browser.newPage(); const page = await browser.newPage();
+11 -8
View File
@@ -16,7 +16,8 @@ import { withRepresentation } from "./center-shared"
// Owners: ui-viz docs/visualization/facility.md and facility-assets.md (128 approved SVG references, shipped unchanged). // Owners: ui-viz docs/visualization/facility.md and facility-assets.md (128 approved SVG references, shipped unchanged).
// The display scale (Q2b) is HOLD and Facility has no Selected / Hover state. // The display scale (Q2b) is HOLD and Facility has no Selected / Hover state.
const meta = { const meta = {
title: "Center Graphic/Facility", title: "노선도/시설물",
id: "center-graphic-facility",
component: TunnelFacility, component: TunnelFacility,
parameters: { layout: "padded" }, parameters: { layout: "padded" },
} satisfies Meta<typeof TunnelFacility> } satisfies Meta<typeof TunnelFacility>
@@ -30,23 +31,25 @@ const page = (name: string, Demo: ComponentType): Story => ({
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title={name} /> <StoryPageHeader title={name} />
<StorySection title="Overview"> <StorySection title="개요">
{withRepresentation(() => <Demo />)} {withRepresentation(() => <Demo />)}
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
}) })
export const Tunnel: Story = page("터널", TunnelReferenceDemo) // `name` is repeated as a literal because Storybook's static indexer cannot read it through page(); it drives the sidebar label.
export const Bridge: Story = page("교량", BridgeReferenceDemo) export const Tunnel: Story = { ...page("터널", TunnelReferenceDemo), name: "터널" }
export const Intersection: Story = page("교차로", IntersectionReferenceDemo) export const Bridge: Story = { ...page("교량", BridgeReferenceDemo), name: "교량" }
export const Junction: Story = page("분기점", JunctionReferenceDemo) export const Intersection: Story = { ...page("교차로", IntersectionReferenceDemo), name: "교차로" }
export const Interchange: Story = page("나들목", InterchangeReferenceDemo) export const Junction: Story = { ...page("분기점", JunctionReferenceDemo), name: "분기점" }
export const Roadside: Story = page("휴게소 · 졸음쉼터", RoadsideReferenceDemo) export const Interchange: Story = { ...page("나들목", InterchangeReferenceDemo), name: "나들목" }
export const Roadside: Story = { ...page("휴게소 · 졸음쉼터", RoadsideReferenceDemo), name: "휴게소 · 졸음쉼터" }
// Linked facilities are a standalone section heading followed by item cards. // Linked facilities are a standalone section heading followed by item cards.
// Keep the reference data/artwork and road-class grouping owned by the package. // Keep the reference data/artwork and road-class grouping owned by the package.
export const Linked: Story = { export const Linked: Story = {
...page("연계 시설물", LinkedFacilityReferenceDemo), ...page("연계 시설물", LinkedFacilityReferenceDemo),
name: "연계 시설물",
render: () => ( render: () => (
<StoryPage className="sb-center-linked-page"> <StoryPage className="sb-center-linked-page">
<StoryPageHeader title="연계 시설물" /> <StoryPageHeader title="연계 시설물" />
+5 -4
View File
@@ -6,7 +6,8 @@ import { withRepresentation } from "./center-shared"
// Owner: ui-viz docs/patterns/center-information-presentation.md and center-information-{box,table,emphasis}.md. // Owner: ui-viz docs/patterns/center-information-presentation.md and center-information-{box,table,emphasis}.md.
// Single-line ellipsis is resolved; multi-line overflow, exact placement and the presentation scale (Q3) are not. // Single-line ellipsis is resolved; multi-line overflow, exact placement and the presentation scale (Q3) are not.
const meta = { const meta = {
title: "Center Graphic/Information", title: "노선도/정보",
id: "center-graphic-information",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
} satisfies Meta } satisfies Meta
export default meta export default meta
@@ -14,11 +15,11 @@ type Story = StoryObj<typeof meta>
// Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic. // Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic.
export const Information: Story = { export const Information: Story = {
name: "Information", name: "정보",
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Information" /> <StoryPageHeader title="정보" />
<StorySection title="Overview"> <StorySection title="개요">
{withRepresentation(() => ( {withRepresentation(() => (
<div className="sb-center-information"> <div className="sb-center-information">
<CenterInformationDisplayReferenceDemo /> <CenterInformationDisplayReferenceDemo />
+1 -1
View File
@@ -20,7 +20,7 @@ const STATUS: [string, string, string, string][] = [
["Color Candidate application", "TBD", "foundation/center-color.md §6–7", "Reference 그대로"], ["Color Candidate application", "TBD", "foundation/center-color.md §6–7", "Reference 그대로"],
] ]
const meta = { title: "Center Graphic/Overview", parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta const meta = { title: "노선도/Overview", id: "center-graphic-overview", parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
+5 -4
View File
@@ -7,7 +7,8 @@ import { withRepresentation } from "./center-shared"
// Owners: ui-viz docs/visualization/route.md, spatial-map.md, spatial-section.md, composition.md. // Owners: ui-viz docs/visualization/route.md, spatial-map.md, spatial-section.md, composition.md.
// Route layer order Shadow -> Outline -> Primary Stroke is CONFIRMED; the whole-map layer order is Candidate and line join is TBD. // Route layer order Shadow -> Outline -> Primary Stroke is CONFIRMED; the whole-map layer order is Candidate and line join is TBD.
const meta = { const meta = {
title: "Center Graphic/Spatial", title: "노선도/노선·공간",
id: "center-graphic-spatial",
component: RouteLine, component: RouteLine,
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
} satisfies Meta<typeof RouteLine> } satisfies Meta<typeof RouteLine>
@@ -16,11 +17,11 @@ type Story = StoryObj<typeof meta>
// Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic. // Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic.
export const Spatial: Story = { export const Spatial: Story = {
name: "Spatial", name: "노선·공간",
render: () => ( render: () => (
<StoryPage className="sb-center-spatial-page"> <StoryPage className="sb-center-spatial-page">
<StoryPageHeader title="Spatial" /> <StoryPageHeader title="노선·공간" />
<StorySection title="Overview"> <StorySection title="개요">
{withRepresentation(() => <CenterSpatialInformationReferenceDemo />)} {withRepresentation(() => <CenterSpatialInformationReferenceDemo />)}
</StorySection> </StorySection>
</StoryPage> </StoryPage>
+5 -4
View File
@@ -6,7 +6,8 @@ import { withRepresentation } from "./center-shared"
// Owner: ui-viz docs/components/center-title.md. Title Resolution B numeric output is TBD; the per-Type palette is SCOPED_DESIGN_APPROVAL. // Owner: ui-viz docs/components/center-title.md. Title Resolution B numeric output is TBD; the per-Type palette is SCOPED_DESIGN_APPROVAL.
const meta = { const meta = {
title: "Center Graphic/Title", title: "노선도/사업명",
id: "center-graphic-title",
component: CenterTitle, component: CenterTitle,
parameters: { layout: "padded" }, parameters: { layout: "padded" },
} satisfies Meta<typeof CenterTitle> } satisfies Meta<typeof CenterTitle>
@@ -16,12 +17,12 @@ type Story = StoryObj<typeof meta>
// Representative Story: the four title types. Named after the area so Storybook shows it as a single sidebar item. // Representative Story: the four title types. Named after the area so Storybook shows it as a single sidebar item.
// Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic. // Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic.
export const Compositions: Story = { export const Compositions: Story = {
name: "Title", name: "사업명",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Title" /> <StoryPageHeader title="사업명" />
<StorySection title="Overview"> <StorySection title="개요">
{withRepresentation(() => <CenterTitleReferenceDemo />)} {withRepresentation(() => <CenterTitleReferenceDemo />)}
</StorySection> </StorySection>
</StoryPage> </StoryPage>
+18 -9
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from "react" import type { ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@hm-design/ui" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// The representative "Accordion" Story puts each Radix mode on one page with real, clickable items. The same sections also exist // The representative "Accordion" Story puts each Radix mode on one page with real, clickable items. The same sections also exist
@@ -115,8 +115,21 @@ const meta = { title: "Components/Disclosure/Accordion", parameters: { layout: "
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), one column
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} /> // below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so the title, divider and
// specimen start line up, unused columns stay empty and `items-start` lets an opened item grow without touching its neighbors.
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
return (
<div className={`grid grid-cols-1 items-start gap-6 ${cols === 3 ? "min-[1000px]:grid-cols-3" : "min-[1000px]:grid-cols-2"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
const board = (cols: 2 | 3, ...ids: string[]) => (
<StateGrid cols={cols} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
) )
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
@@ -126,12 +139,8 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Accordion" /> <StoryPageHeader title="Accordion" />
<StorySection title="Overview">{board("single")}</StorySection> <StorySection title="Variants">{board(3, "single", "collapsible-single", "multiple")}</StorySection>
<StorySection title="Variants"> <StorySection title="States">{board(3, "disabled", "long")}</StorySection>
{board("collapsible-single", "multiple")}
</StorySection>
<StorySection title="States">{board("disabled")}</StorySection>
<StorySection title="Usage">{board("long")}</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+22 -4
View File
@@ -11,6 +11,7 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
Button, Button,
} from "@hm-design/ui" } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Alert Dialog is a modal Radix overlay (Portal) for a decision that must be confirmed or cancelled: no Close (X), no outside // Alert Dialog is a modal Radix overlay (Portal) for a decision that must be confirmed or cancelled: no Close (X), no outside
@@ -68,17 +69,34 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative Story: the common Story page; each trigger opens the real dialog. // Representative 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
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: Overview and Variants are merged into one Variants section.
export const All: Story = { export const All: Story = {
name: "Alert Dialog", name: "Alert Dialog",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Alert Dialog" /> <StoryPageHeader title="Alert Dialog" />
<StorySection title="Overview">
<Confirmation />
</StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<Destructive /> <SpecimenGrid
states={[
{ id: "confirmation", label: "Confirmation", children: <Confirmation /> },
{ id: "destructive", label: "Destructive", children: <Destructive /> },
]}
/>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+3 -10
View File
@@ -27,9 +27,9 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Alert" /> <StoryPageHeader title="Alert" />
<StorySection title="Overview"> <StorySection title="Variants">
<StateBoard <StateBoard
min={320} min={360}
states={[ states={[
{ {
id: "default", id: "default",
@@ -42,13 +42,6 @@ export const All: Story = {
</Alert> </Alert>
), ),
}, },
]}
/>
</StorySection>
<StorySection title="Variants">
<StateBoard
min={320}
states={[
{ {
id: "destructive", id: "destructive",
label: "Destructive", label: "Destructive",
@@ -65,7 +58,7 @@ export const All: Story = {
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<StateBoard <StateBoard
min={320} min={360}
states={[ states={[
{ {
id: "no-icon", id: "no-icon",
+30 -5
View File
@@ -57,20 +57,45 @@ const meta = { title: "Components/Data & Display/Aspect Ratio", component: Aspec
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
<StateBoard min={340} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} /> // (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 Card example spans two columns (about its previous width); the ratio boxes fill their column by design.
const specimenColumns = {
2: "min-[1000px]:grid-cols-2",
3: "min-[1000px]:grid-cols-3",
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${specimenColumns[columns]}`}>
{ids.map((id) => {
const s = sections.find((section) => section.id === id)!
return (
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
</div>
) )
})}
</div>
)
}
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// Representative Story: Overview (16:9) is merged into Variants (16:9, 4:3, 1:1, Video), 4 columns on PC; Card stays under Usage.
export const All: Story = { export const All: Story = {
name: "Aspect Ratio", name: "Aspect Ratio",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Aspect Ratio" /> <StoryPageHeader title="Aspect Ratio" />
<StorySection title="Overview">{board("r169")}</StorySection> <StorySection title="Variants">
<StorySection title="Variants">{board("r43", "r11", "video")}</StorySection> <SpecimenGrid ids={["r169", "r43", "r11", "video"]} columns={4} />
<StorySection title="Usage">{board("card")}</StorySection> </StorySection>
<StorySection title="Usage">
<SpecimenGrid ids={["card"]} columns={4} spans={{ card: 2 }} />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+29 -5
View File
@@ -76,20 +76,44 @@ const meta = { title: "Components/Data & Display/Avatar", component: Avatar, par
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} /> // (240px 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.
const specimenColumns = {
2: "min-[1000px]:grid-cols-2",
3: "min-[1000px]:grid-cols-3",
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}>
{ids.map((id) => {
const s = sections.find((section) => section.id === id)!
return (
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
</div>
) )
})}
</div>
)
}
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// Representative Story: Overview / States are merged into Variants (Image, Fallback); Composition stays under Usage. 3 columns on PC.
export const All: Story = { export const All: Story = {
name: "Avatar", name: "Avatar",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Avatar" /> <StoryPageHeader title="Avatar" />
<StorySection title="Overview">{board("image")}</StorySection> <StorySection title="Variants">
<StorySection title="States">{board("fallback")}</StorySection> <SpecimenGrid ids={["image", "fallback"]} columns={3} />
<StorySection title="Usage">{board("composition")}</StorySection> </StorySection>
<StorySection title="Usage">
<SpecimenGrid ids={["composition"]} columns={3} />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+25 -8
View File
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { CheckIcon } from "lucide-react" import { CheckIcon } from "lucide-react"
import { Badge } from "@hm-design/ui" import { Badge } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
const variants = ["default", "secondary", "destructive", "outline"] as const const variants = ["default", "secondary", "destructive", "outline"] as const
@@ -23,18 +23,35 @@ const meta = {
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (280px 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.
const specimenColumns = {
2: "min-[1000px]:grid-cols-2",
3: "min-[1000px]:grid-cols-3",
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: 2 | 3 | 4 }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,280px),1fr))] ${specimenColumns[columns]}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
// Representative Story: Overview (a plain Badge, already the default item of Variants / Text) is removed. 2 columns on PC.
export const All: Story = { export const All: Story = {
name: "Badge", name: "Badge",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Badge" /> <StoryPageHeader title="Badge" />
<StorySection title="Overview">
<Badge>Badge</Badge>
</StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<StateBoard <SpecimenGrid
min={280} columns={2}
states={[ states={[
{ {
id: "variants", id: "variants",
@@ -67,8 +84,8 @@ export const All: Story = {
/> />
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<StateBoard <SpecimenGrid
min={280} columns={2}
states={[ states={[
{ id: "long", label: "Long text", children: <div className="max-w-[160px]"><Badge>A very long badge label</Badge></div> }, { id: "long", label: "Long text", children: <div className="max-w-[160px]"><Badge>A very long badge label</Badge></div> },
{ {
+20 -7
View File
@@ -13,7 +13,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@hm-design/ui" } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// The representative "Breadcrumb" Story shows each case on one page (real links with sample hrefs); the same specimens also exist // The representative "Breadcrumb" Story shows each case on one page (real links with sample hrefs); the same specimens also exist
@@ -122,20 +122,33 @@ const meta = { title: "Components/Navigation/Breadcrumb", component: Breadcrumb,
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): Variants use 3 equal columns on PC (iframe width 1000px and up),
<StateBoard min={420} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: narrow(s.node) }))} /> // fewer below that (240px minimum). 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)!
return { id: s.id, label: s.label, children: children ?? narrow(s.node) }
}
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// Overview is merged into Variants (Basic is its first item). The Long path example gets up to 900px so it fits on one line on
// PC; BreadcrumbList is `flex-wrap`, so it wraps naturally (never clipped or forced nowrap) when the space is narrower.
export const All: Story = { export const All: Story = {
name: "Breadcrumb", name: "Breadcrumb",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Breadcrumb" /> <StoryPageHeader title="Breadcrumb" />
<StorySection title="Overview">{board("basic")}</StorySection> <StorySection title="Variants">
<StorySection title="Variants">{board("ellipsis", "custom")}</StorySection> <div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
<StorySection title="Usage">{board("long")}</StorySection> {["basic", "ellipsis", "custom"].map((id) => (
<StateBoard key={id} min={10000} states={[specimen(id)]} />
))}
</div>
</StorySection>
<StorySection title="Usage">
<StateBoard min={10000} states={[specimen("long", <div className="w-full max-w-[900px]">{sections.find((s) => s.id === "long")!.node}</div>)]} />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+72 -17
View File
@@ -2,7 +2,7 @@ import { useState } from "react"
import { DownloadIcon } from "lucide-react" import { DownloadIcon } from "lucide-react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button, type ButtonAppearance } from "@hm-design/ui" import { Button, type ButtonAppearance } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx): // Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx):
@@ -159,12 +159,13 @@ const standardStates = [
// Without `title` the table sits under a StateBoard label (representative page) and drops the Role column: // Without `title` the table sits under a StateBoard label (representative page) and drops the Role column:
// every button already shows its role name, so the row header would repeat it. // every button already shows its role name, so the row header would repeat it.
function StandardStateTable({ appearance, title }: { appearance: "solid" | "outline"; title?: string }) { // `compact` (representative page only) tightens the cell padding so a 5x4 / 4x4 matrix stays short; buttons are untouched.
function StandardStateTable({ appearance, title, compact = false }: { appearance: "solid" | "outline"; title?: string; compact?: boolean }) {
return ( return (
<section className="min-w-0"> <section className="min-w-0">
{title ? <h3 className={heading}>{title}</h3> : null} {title ? <h3 className={heading}>{title}</h3> : null}
<div className={`${title ? "mt-3 " : ""}min-w-0 max-w-full overflow-x-auto`}> <div className={`${title ? "mt-3 " : ""}min-w-0 max-w-full overflow-x-auto`}>
<table className="w-full border-collapse [&_td]:p-3 [&_th]:p-3 [&_th]:text-left"> <table className={`w-full border-collapse [&_th]:text-left ${compact ? "[&_td]:px-2 [&_td]:py-1 [&_th]:px-2 [&_th]:py-1" : "[&_td]:p-3 [&_th]:p-3"}`}>
<thead> <thead>
<tr> <tr>
{title ? <th scope="col">Role</th> : null} {title ? <th scope="col">Role</th> : null}
@@ -230,31 +231,85 @@ function DestructiveBoard() {
) )
} }
// Representative Story: the common Story page (Overview → Sizes → Variants → States) with the ui_viz Button "All" boards. // Compact PC layout of the representative page (Storybook presentation only; Button itself is unchanged).
// Page and section spacing are tightened for this Story only through `compactPage`; the shared story-page.css is not edited.
// The important modifier is required because the shared rules use the same specificity.
const compactPage = [
"py-4!",
"[&_.sb-page-header]:pb-3!",
"[&_.sb-page-title]:mt-1!",
"[&_.sb-page-title]:mb-2!",
"[&_.sb-page-section]:mt-5!",
"[&_.sb-page-section-header]:pb-2!",
"[&_.sb-page-section-header]:mb-3!",
].join(" ")
// 8px gap so the three Brand sizes stay on one line in the narrowest (Brand) column, same as the Variants rows.
const compactRow = "flex flex-wrap items-center gap-2"
// Equal two columns over the whole content width. StateBoard's own auto-fill grid left an empty third column
// (2 items in a grid that fits 3), so the pairs are laid out here and each StateBoard holds one specimen.
const twoColumns = "grid gap-6 min-[900px]:grid-cols-[repeat(2,minmax(0,1fr))]"
// StateBoard lays its states out in an auto-fill grid. A board that holds one specimen inside a wide column would put it
// in the first of several inner columns (about half width at 1920px), so `min` is set larger than any column to force one.
const SingleColumnBoard = ({ states }: { states: BoardState[] }) => <StateBoard min={10000} states={states} />
const columnsAuto = (min: number) => `[grid-template-columns:repeat(auto-fit,minmax(min(100%,${min}px),1fr))]`
// 8px gap keeps all three Variants groups on one line at the 1280px PC width (Brand 3 + Solid 5 + Outline 4 buttons in a 3-column grid); narrower widths wrap.
function CompactRoleRow({ appearance }: { appearance: (typeof appearances)[number][0] }) {
return (
<div className="flex flex-wrap items-center gap-2">
{rolesFor(appearance).map(([variant, label]) => (
<Button key={variant} variant={variant} appearance={appearance}>
{label}
</Button>
))}
</div>
)
}
function CompactSizeRow({ appearance }: { appearance: "brand" | "solid" }) {
return (
<div className={compactRow}>
{sizes.map(([size, label]) => (
<Button key={size} appearance={appearance} size={size}>
{label}
</Button>
))}
</div>
)
}
// Representative Story: the common Story page (Sizes → Variants → States) with the ui_viz Button "All" boards.
// The former Overview button was the default Brand Medium button already shown under Sizes, so Overview is merged into Sizes.
// Named after the component, as in ui_viz. It is the only Button entry in the default sidebar, so Storybook shows Button as a single 1-depth item. // Named after the component, as in ui_viz. It is the only Button entry in the default sidebar, so Storybook shows Button as a single 1-depth item.
export const All: Story = { export const All: Story = {
name: "Button", name: "Button",
parameters: { layout: "fullscreen" }, parameters: { layout: "fullscreen" },
render: () => ( render: () => (
<StoryPage> <StoryPage className={compactPage}>
<StoryPageHeader title="Button" /> <StoryPageHeader title="Button" />
<StorySection title="Overview">
<Button>Button</Button>
</StorySection>
<StorySection title="Sizes"> <StorySection title="Sizes">
<SizesBoard /> {/* Same columns as Variants below (Brand / Standard Solid / Standard Outline): Brand and Standard Solid sizes line up with
their Variants columns and the third column stays empty. */}
<div className={`grid gap-6 ${columnsAuto(260)} min-[960px]:[grid-template-columns:minmax(0,234fr)_minmax(0,353fr)_minmax(0,271fr)]`}>
<SingleColumnBoard states={[{ id: "sizes-brand", label: "Brand", children: <CompactSizeRow appearance="brand" /> }]} />
<SingleColumnBoard states={[{ id: "sizes-solid", label: "Standard Solid", children: <CompactSizeRow appearance="solid" /> }]} />
</div>
</StorySection> </StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<StateBoard states={appearances.map(([appearance, label]) => ({ id: `roles-${appearance}`, label, wide: true, children: <RoleRow appearance={appearance} /> }))} /> <div className={`grid gap-6 ${columnsAuto(260)} min-[960px]:[grid-template-columns:minmax(0,234fr)_minmax(0,353fr)_minmax(0,271fr)]`}>
{appearances.map(([appearance, label]) => (
<SingleColumnBoard key={appearance} states={[{ id: `roles-${appearance}`, label, children: <CompactRoleRow appearance={appearance} /> }]} />
))}
</div>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection title="States">
<StateBoard <div className="grid gap-6">
states={[ <SingleColumnBoard states={[{ id: "brand-states", label: "Brand", wide: true, children: <BrandStates showNote={false} /> }]} />
{ id: "brand-states", label: "Brand", wide: true, children: <BrandStates showNote={false} /> }, <div className={twoColumns}>
{ id: "standard-solid-states", label: "Standard Solid", wide: true, children: <StandardStateTable appearance="solid" /> }, <SingleColumnBoard states={[{ id: "standard-solid-states", label: "Standard Solid", children: <StandardStateTable appearance="solid" compact /> }]} />
{ id: "standard-outline-states", label: "Standard Outline", wide: true, children: <StandardStateTable appearance="outline" /> }, <SingleColumnBoard states={[{ id: "standard-outline-states", label: "Standard Outline", children: <StandardStateTable appearance="outline" compact /> }]} />
]} </div>
/> </div>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+22 -13
View File
@@ -48,22 +48,22 @@ function TwoMonths() {
return box(<Calendar mode="single" numberOfMonths={2} defaultMonth={month} today={today} />) return box(<Calendar mode="single" numberOfMonths={2} defaultMonth={month} today={today} />)
} }
const sections = [ // Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and
{ id: "single", label: "Single", node: <Single showValue={false} /> }, // specimen tops line up; the wide Two months specimen takes two tracks (the full row on narrow screens).
{ id: "disabled", label: "Disabled dates", node: <DisabledDates /> }, const Grid = ({ children }: { children: ReactNode }) => (
{ id: "range", label: "Range", node: <Range /> }, <div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
{ id: "months", label: "Two months", node: <TwoMonths /> }, )
] const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?: boolean; children: ReactNode }) => (
<div className={wide ? "col-span-full min-w-0 min-[1000px]:col-span-2" : "min-w-0"}>
<StateBoard min={10000} states={[{ id, label, children }]} />
</div>
)
// No `component`: Calendar props are a union of selection modes, so Stories without args would not type-check. // No `component`: Calendar props are a union of selection modes, so Stories without args would not type-check.
const meta = { title: "Components/Search & Date/Calendar", parameters: { layout: "padded" } } satisfies Meta const meta = { title: "Components/Search & Date/Calendar", parameters: { layout: "padded" } } satisfies Meta
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => (
<StateBoard min={300} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, wide: s.id === "months", children: s.node }))} />
)
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
export const All: Story = { export const All: Story = {
name: "Calendar", name: "Calendar",
@@ -71,9 +71,18 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Calendar" /> <StoryPageHeader title="Calendar" />
<StorySection title="Overview">{board("single")}</StorySection> <StorySection title="Variants">
<StorySection title="Variants">{board("range", "months")}</StorySection> <Grid>
<StorySection title="States">{board("disabled")}</StorySection> <Cell id="single" label="Single"><Single showValue={false} /></Cell>
<Cell id="range" label="Range"><Range /></Cell>
<Cell id="months" label="Two months" wide><TwoMonths /></Cell>
</Grid>
</StorySection>
<StorySection title="States">
<Grid>
<Cell id="disabled" label="Disabled dates"><DisabledDates /></Cell>
</Grid>
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+23 -9
View File
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button } from "@hm-design/ui" import { Button } from "@hm-design/ui"
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type CardSurface } from "@hm-design/ui" import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type CardSurface } from "@hm-design/ui"
import { Input } from "@hm-design/ui" import { Input } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import type { BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows Button / Input / Select: one representative "Card" Story shows the ui_viz cases on one page, // Story structure follows Button / Input / Select: one representative "Card" Story shows the ui_viz cases on one page,
@@ -34,6 +34,23 @@ function Specimen({ surface = "surface", action, title = "프로젝트 정보",
) )
} }
// Story-only equal-column board for this page: 3 columns on PC, 2 / 1 on narrower widths. Variants and Usage share it,
// so a Usage cell has the same width as one Variants cell. Label markup/colors match StateBoard (shared component untouched).
function CardBoard({ states }: { states: BoardState[] }) {
return (
<div className="grid grid-cols-1 items-start gap-x-6 gap-y-8 min-[640px]:grid-cols-2 min-[900px]:grid-cols-3">
{states.map(({ id, label, children }) => (
<section className="grid min-w-0 content-start gap-3" key={id} data-overview-state={id} aria-label={label}>
<header className="grid gap-2 border-b border-black/10 pb-2">
<h3 className="text-sm font-medium text-muted-foreground">{label}</h3>
</header>
{children}
</section>
))}
</div>
)
}
function FormSpecimen() { function FormSpecimen() {
return ( return (
<Card> <Card>
@@ -42,7 +59,7 @@ function FormSpecimen() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-2"> <div className="grid gap-2">
<label htmlFor="card-form-name" className="text-sm font-medium text-muted-foreground"> <label htmlFor="card-form-name" className="text-sm font-medium text-foreground">
프로젝트명 프로젝트명
</label> </label>
<Input id="card-form-name" size="medium" placeholder="프로젝트명을 입력하세요" /> <Input id="card-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
@@ -63,7 +80,7 @@ const meta = {
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative Story: the common Story page (Overview → Variants → Usage) with every ui_viz case. Named after the component, as in ui_viz. // Representative Story: the common Story page (Variants → Usage) with every ui_viz case. Named after the component, as in ui_viz.
// It is the only Card entry in the default sidebar, so Storybook shows Card as a single 1-depth item. // It is the only Card entry in the default sidebar, so Storybook shows Card as a single 1-depth item.
export const All: Story = { export const All: Story = {
name: "Card", name: "Card",
@@ -71,20 +88,17 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Card" /> <StoryPageHeader title="Card" />
<StorySection title="Overview">
<StateBoard min={360} states={[{ id: "surface", label: "White", children: <Specimen /> }]} />
</StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<StateBoard <CardBoard
min={360}
states={[ states={[
{ id: "surface", label: "White", children: <Specimen /> },
{ id: "content", label: "Content (Beige)", children: <Specimen surface="content" /> }, { id: "content", label: "Content (Beige)", children: <Specimen surface="content" /> },
{ id: "action", label: "Title + Action", children: <Specimen action /> }, { id: "action", label: "Title + Action", children: <Specimen action /> },
]} ]}
/> />
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<StateBoard min={360} states={[{ id: "form", label: "본문 조합 · Input", children: <FormSpecimen /> }]} /> <CardBoard states={[{ id: "form", label: "본문 조합 · Input", children: <FormSpecimen /> }]} />
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+30 -5
View File
@@ -142,20 +142,45 @@ const meta = { title: "Components/Data & Display/Carousel", component: Carousel,
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
<StateBoard min={460} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} /> // (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 Media example spans two columns; carousels fill their column by design.
const specimenColumns = {
2: "min-[1000px]:grid-cols-2",
3: "min-[1000px]:grid-cols-3",
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${specimenColumns[columns]}`}>
{ids.map((id) => {
const s = sections.find((section) => section.id === id)!
return (
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
</div>
) )
})}
</div>
)
}
// Representative Story: the common Story page; the sections group the same specimens. // 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 = { export const All: Story = {
name: "Carousel", name: "Carousel",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Carousel" /> <StoryPageHeader title="Carousel" />
<StorySection title="Overview">{board("basic")}</StorySection> <StorySection title="Variants">
<StorySection title="Variants">{board("multiple", "loop", "vertical")}</StorySection> <SpecimenGrid ids={["basic", "multiple", "loop", "vertical"]} columns={4} />
<StorySection title="Usage">{board("media")}</StorySection> </StorySection>
<StorySection title="Usage">
<SpecimenGrid ids={["media"]} columns={4} spans={{ media: 2 }} />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+37 -32
View File
@@ -1,18 +1,48 @@
import { useId, useState, type ReactNode } from "react" import { useId, useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Checkbox, Label } from "@hm-design/ui" import { Checkbox, Label } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Current HM Checkbox. ui-viz has no Checkbox spec, so it was reference only. // 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. // 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 }) { function Option({ label, children }: { label: string; children: (id: string) => ReactNode }) {
const id = useId() const id = useId()
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{children(id)} {children(id)}
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id} className={optionLabel}>{label}</Label>
</div>
)
}
// Representative-page layout (Storybook presentation only): 3 equal columns on PC (iframe width 1000px and up), shrinking
// to fit below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so StateBoard's
// auto-fill grid cannot leave an empty or stretched track.
function StateGrid({ states }: { states: BoardState[] }) {
return (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] min-[1000px]:grid-cols-3">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
// The wrapping Long Label example (former Usage section of the representative page), also used by the hidden `LongLabel` Story.
function LongLabelExample() {
return (
<div className="max-w-[220px]">
<div className="flex items-start gap-2">
<Checkbox id="cb-long" className="mt-0.5" />
<Label htmlFor="cb-long" className={optionLabel}>A very long checkbox label that wraps onto multiple lines</Label>
</div>
</div> </div>
) )
} }
@@ -49,20 +79,12 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Checkbox" /> <StoryPageHeader title="Checkbox" />
<StorySection title="Overview"> <StorySection title="States">
<StateBoard <StateGrid
min={240}
states={[ states={[
{ id: "unchecked", label: "Unchecked", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> }, { id: "unchecked", label: "Unchecked", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
{ id: "checked", label: "Checked", children: <Option label="Option">{(id) => <Checkbox id={id} defaultChecked />}</Option> }, { id: "checked", label: "Checked", children: <Option label="Option">{(id) => <Checkbox id={id} defaultChecked />}</Option> },
{ id: "indeterminate", label: "Indeterminate", children: <Controlled /> }, { id: "indeterminate", label: "Indeterminate", children: <Controlled /> },
]}
/>
</StorySection>
<StorySection title="States">
<StateBoard
min={240}
states={[
{ {
id: "hover", id: "hover",
label: "Hover", label: "Hover",
@@ -89,26 +111,7 @@ export const All: Story = {
/> />
</StorySection> </StorySection>
<StorySection title="Validation"> <StorySection title="Validation">
<StateBoard min={240} states={[{ id: "error", label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} /> <StateGrid states={[{ id: "error", label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
</StorySection>
<StorySection title="Usage">
<StateBoard
min={240}
states={[
{
id: "long",
label: "Long Label",
children: (
<div className="max-w-[220px]">
<div className="flex items-start gap-2">
<Checkbox id="cb-long" className="mt-0.5" />
<Label htmlFor="cb-long">A very long checkbox label that wraps onto multiple lines</Label>
</div>
</div>
),
},
]}
/>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
@@ -123,6 +126,8 @@ export const Checked: Story = { tags: ["verification"], render: () => <Option la
export const Indeterminate: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} checked="indeterminate" />}</Option> } export const Indeterminate: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} checked="indeterminate" />}</Option> }
export const Disabled: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option> } export const Disabled: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option> }
export const Error: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> } export const Error: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }
// Former Usage section of the representative page, kept as its own Story.
export const LongLabel: Story = { tags: ["verification"], name: "Long Label", render: () => <LongLabelExample /> }
export const Playground: Story = { export const Playground: Story = {
tags: ["verification"], tags: ["verification"],
args: { disabled: false, defaultChecked: false }, args: { disabled: false, defaultChecked: false },
+18 -6
View File
@@ -2,7 +2,7 @@ import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { ChevronDownIcon } from "lucide-react" import { ChevronDownIcon } from "lucide-react"
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from "@hm-design/ui" import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// The representative "Collapsible" Story shows one independent disclosure per state with real, clickable triggers. The same // The representative "Collapsible" Story shows one independent disclosure per state with real, clickable triggers. The same
@@ -70,8 +70,21 @@ const meta = { title: "Components/Disclosure/Collapsible", component: Collapsibl
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), one column
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} /> // below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so the title, divider and
// specimen start line up, unused columns stay empty and `items-start` lets an opened item grow without touching its neighbors.
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
return (
<div className={`grid grid-cols-1 items-start gap-6 ${cols === 3 ? "min-[1000px]:grid-cols-3" : "min-[1000px]:grid-cols-2"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
const board = (cols: 2 | 3, ...ids: string[]) => (
<StateGrid cols={cols} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
) )
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
@@ -81,9 +94,8 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Collapsible" /> <StoryPageHeader title="Collapsible" />
<StorySection title="Overview">{board("closed")}</StorySection> <StorySection title="States">{board(3, "closed", "open", "disabled")}</StorySection>
<StorySection title="States">{board("open", "disabled")}</StorySection> <StorySection title="Usage">{board(3, "long", "controlled")}</StorySection>
<StorySection title="Usage">{board("long", "controlled")}</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+30 -23
View File
@@ -21,10 +21,11 @@ const longOptions: ComboboxOption[] = [
{ value: "b", label: "짧은 옵션" }, { value: "b", label: "짧은 옵션" },
] ]
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) { // Label 14/20/500 in the default text color; a Disabled field keeps the secondary color at 0.6 (same as the Form pages).
function Field({ label, id, disabled, children }: { label: string; id: string; disabled?: boolean; children: ReactNode }) {
return ( return (
<div className="grid max-w-xs gap-2"> <div className="grid max-w-xs gap-2">
<label htmlFor={id} className="justify-self-start text-sm font-medium text-muted-foreground"> <label htmlFor={id} className={`justify-self-start text-sm font-medium ${disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}>
{label} {label}
</label> </label>
{children} {children}
@@ -71,7 +72,7 @@ function LongLabel() {
function Disabled() { function Disabled() {
return ( return (
<Field label="비활성" id="cb-disabled"> <Field label="비활성" id="cb-disabled" disabled>
<Combobox id="cb-disabled" options={frameworks} defaultValue="next" disabled /> <Combobox id="cb-disabled" options={frameworks} defaultValue="next" disabled />
</Field> </Field>
) )
@@ -99,22 +100,22 @@ function SelectComparison() {
) )
} }
const sections = [ // Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and
{ id: "empty", label: "Placeholder", node: <Empty /> }, // fields line up; sections with fewer specimens leave the remaining tracks empty. A wide cell takes two tracks (the full row on narrow screens).
{ id: "controlled", label: "Selected", node: <Controlled showValue={false} /> }, const Grid = ({ children }: { children: ReactNode }) => (
{ id: "long", label: "Long option", node: <LongLabel /> }, <div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
{ id: "disabled", label: "Disabled", node: <Disabled /> }, )
] const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?: boolean; children: ReactNode }) => (
<div className={wide ? "col-span-full min-w-0 min-[1000px]:col-span-2" : "min-w-0"}>
<StateBoard min={10000} states={[{ id, label, children }]} />
</div>
)
// No `component`: Combobox has a required `options` prop, so Stories without args would not type-check. // No `component`: Combobox has a required `options` prop, so Stories without args would not type-check.
const meta = { title: "Components/Search & Date/Combobox", parameters: { layout: "padded" } } satisfies Meta const meta = { title: "Components/Search & Date/Combobox", parameters: { layout: "padded" } } satisfies Meta
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => (
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
export const All: Story = { export const All: Story = {
name: "Combobox", name: "Combobox",
@@ -122,18 +123,24 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Combobox" /> <StoryPageHeader title="Combobox" />
<StorySection title="Overview">{board("empty")}</StorySection>
<StorySection title="Sizes"> <StorySection title="Sizes">
{/* The field label already names the size, so the Medium specimen has no separate example title. */} <Grid>
<div className="grid gap-10"> <Cell id="empty" label="Default"><Empty /></Cell>
<div className="max-w-sm"> <Cell id="medium" label="Medium"><Medium /></Cell>
<Medium /> </Grid>
</div> </StorySection>
<StateBoard min={320} states={[{ id: "select-comparison", label: "Select / Combobox", wide: true, children: <SelectComparison /> }]} /> <StorySection title="States">
</div> <Grid>
<Cell id="controlled" label="Selected"><Controlled showValue={false} /></Cell>
<Cell id="disabled" label="Disabled"><Disabled /></Cell>
</Grid>
</StorySection>
<StorySection title="Usage">
<Grid>
<Cell id="long" label="Long option"><LongLabel /></Cell>
<Cell id="select-comparison" label="Select / Combobox" wide><SelectComparison /></Cell>
</Grid>
</StorySection> </StorySection>
<StorySection title="States">{board("controlled", "disabled")}</StorySection>
<StorySection title="Usage">{board("long")}</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+21 -12
View File
@@ -80,20 +80,21 @@ function Long() {
) )
} }
const sections = [ // Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and
{ id: "basic", label: "Search", node: <Basic showValue={false} /> }, // command boxes line up; sections with fewer specimens leave the remaining tracks empty.
{ id: "grouped", label: "Groups", node: <Grouped /> }, const Grid = ({ children }: { children: ReactNode }) => (
{ id: "long", label: "Long list", node: <Long /> }, <div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
] )
const Cell = ({ id, label, children }: { id: string; label: string; children: ReactNode }) => (
<div className="min-w-0">
<StateBoard min={10000} states={[{ id, label, children }]} />
</div>
)
const meta = { title: "Components/Search & Date/Command", component: Command, parameters: { layout: "padded" } } satisfies Meta<typeof Command> const meta = { title: "Components/Search & Date/Command", component: Command, parameters: { layout: "padded" } } satisfies Meta<typeof Command>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => (
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
export const All: Story = { export const All: Story = {
name: "Command", name: "Command",
@@ -101,9 +102,17 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Command" /> <StoryPageHeader title="Command" />
<StorySection title="Overview">{board("basic")}</StorySection> <StorySection title="Variants">
<StorySection title="Variants">{board("grouped")}</StorySection> <Grid>
<StorySection title="Usage">{board("long")}</StorySection> <Cell id="basic" label="Search"><Basic showValue={false} /></Cell>
<Cell id="grouped" label="Groups"><Grouped /></Cell>
</Grid>
</StorySection>
<StorySection title="Usage">
<Grid>
<Cell id="long" label="Long list"><Long /></Cell>
</Grid>
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+1 -1
View File
@@ -83,7 +83,7 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Context Menu" /> <StoryPageHeader title="Context Menu" />
<StorySection title="Overview"> <StorySection title="Basic">
<Full /> <Full />
</StorySection> </StorySection>
</StoryPage> </StoryPage>
+38 -25
View File
@@ -12,10 +12,11 @@ import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-pag
const today = new Date(2026, 9, 2) const today = new Date(2026, 9, 2)
const calendarProps = { today, startMonth: new Date(2026, 0), endMonth: new Date(2027, 11) } const calendarProps = { today, startMonth: new Date(2026, 0), endMonth: new Date(2027, 11) }
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) { // Label 14/20/500 in the default text color; a Disabled field keeps the secondary color at 0.6 (same as the Form pages).
function Field({ label, id, disabled, children }: { label: string; id: string; disabled?: boolean; children: ReactNode }) {
return ( return (
<div className="grid max-w-xs gap-2"> <div className="grid max-w-xs gap-2">
<label htmlFor={id} className="justify-self-start text-sm font-medium text-muted-foreground"> <label htmlFor={id} className={`justify-self-start text-sm font-medium ${disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}>
{label} {label}
</label> </label>
{children} {children}
@@ -39,16 +40,19 @@ function Selected() {
) )
} }
function FormatExamples() { function DefaultFormat() {
return ( return (
<div className="grid gap-3">
<Field label="기본 형식 · YYYY.MM.DD" id="dp-padded"> <Field label="기본 형식 · YYYY.MM.DD" id="dp-padded">
<DatePicker id="dp-padded" defaultValue={new Date(2026, 0, 9)} calendarProps={calendarProps} /> <DatePicker id="dp-padded" defaultValue={new Date(2026, 0, 9)} calendarProps={calendarProps} />
</Field> </Field>
)
}
function CustomFormat() {
return (
<Field label="Custom format override" id="dp-custom"> <Field label="Custom format override" id="dp-custom">
<DatePicker id="dp-custom" defaultValue={new Date(2026, 9, 6)} format={(date) => date.toLocaleDateString("ko-KR")} calendarProps={calendarProps} /> <DatePicker id="dp-custom" defaultValue={new Date(2026, 9, 6)} format={(date) => date.toLocaleDateString("ko-KR")} calendarProps={calendarProps} />
</Field> </Field>
</div>
) )
} }
@@ -86,29 +90,27 @@ function DisabledDates() {
function Disabled() { function Disabled() {
return ( return (
<Field label="비활성" id="dp-disabled"> <Field label="비활성" id="dp-disabled" disabled>
<DatePicker id="dp-disabled" disabled defaultValue={new Date(2026, 9, 15)} /> <DatePicker id="dp-disabled" disabled defaultValue={new Date(2026, 9, 15)} />
</Field> </Field>
) )
} }
const sections = [ // Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and
{ id: "empty", label: "Empty", node: <Empty /> }, // fields line up; sections with fewer specimens leave the remaining tracks empty.
{ id: "selected", label: "Selected", node: <Selected /> }, const Grid = ({ children }: { children: ReactNode }) => (
{ id: "format", label: "Format", node: <FormatExamples /> }, <div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
{ id: "controlled", label: "Controlled", node: <Controlled showValue={false} /> }, )
{ id: "disabled-dates", label: "Disabled dates", node: <DisabledDates /> }, const Cell = ({ id, label, children }: { id: string; label: string; children: ReactNode }) => (
{ id: "disabled", label: "Disabled", node: <Disabled /> }, <div className="min-w-0">
] <StateBoard min={10000} states={[{ id, label, children }]} />
</div>
)
const meta = { title: "Components/Search & Date/Date Picker", component: DatePicker, parameters: { layout: "padded" } } satisfies Meta<typeof DatePicker> const meta = { title: "Components/Search & Date/Date Picker", component: DatePicker, parameters: { layout: "padded" } } satisfies Meta<typeof DatePicker>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => (
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
)
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
export const All: Story = { export const All: Story = {
name: "Date Picker", name: "Date Picker",
@@ -116,15 +118,26 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Date Picker" /> <StoryPageHeader title="Date Picker" />
<StorySection title="Overview">{board("empty")}</StorySection>
{/* The field label already names the size, so the Medium specimen has no separate example title. */}
<StorySection title="Sizes"> <StorySection title="Sizes">
<div className="max-w-sm"> <Grid>
<Medium /> <Cell id="empty" label="Default"><Empty /></Cell>
</div> <Cell id="medium" label="Medium"><Medium /></Cell>
</Grid>
</StorySection>
<StorySection title="States">
<Grid>
<Cell id="selected" label="Selected"><Selected /></Cell>
<Cell id="controlled" label="Controlled"><Controlled showValue={false} /></Cell>
<Cell id="disabled-dates" label="Disabled dates"><DisabledDates /></Cell>
<Cell id="disabled" label="Disabled"><Disabled /></Cell>
</Grid>
</StorySection>
<StorySection title="Usage">
<Grid>
<Cell id="format-default" label="Default format"><DefaultFormat /></Cell>
<Cell id="format-custom" label="Custom format"><CustomFormat /></Cell>
</Grid>
</StorySection> </StorySection>
<StorySection title="States">{board("selected", "controlled", "disabled-dates", "disabled")}</StorySection>
<StorySection title="Usage">{board("format")}</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+32 -16
View File
@@ -4,6 +4,7 @@ import { Button } from "@hm-design/ui"
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@hm-design/ui" import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@hm-design/ui"
import { Input } from "@hm-design/ui" import { Input } from "@hm-design/ui"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@hm-design/ui" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// 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), so it does not fit the StateBoard. The representative Story is a row of triggers that open
@@ -161,7 +162,7 @@ function FormContent({ defaultOpen }: { defaultOpen?: boolean }) {
return ( return (
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={confirmActions}> <Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={confirmActions}>
<div className="grid gap-2"> <div className="grid gap-2">
<label htmlFor="dialog-form-name" className="text-sm font-medium text-muted-foreground"> <label htmlFor="dialog-form-name" className="text-sm font-medium text-foreground">
프로젝트명 프로젝트명
</label> </label>
<Input id="dialog-form-name" size="medium" placeholder="프로젝트명을 입력하세요" /> <Input id="dialog-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
@@ -176,29 +177,44 @@ type Story = StoryObj<typeof meta>
// Representative Story: the common Story page; every case opens from its trigger. Named after the component, as in ui_viz. // Representative Story: the common Story page; every case opens from its trigger. Named after the component, as in ui_viz.
// It is the only Dialog entry in the default sidebar, so Storybook shows Dialog as a single 1-depth item. // It is the only Dialog entry in the default sidebar, so Storybook shows Dialog as a single 1-depth item.
// 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 (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: the common Story page. Overview is merged into Variants (Basic is its first item).
export const All: Story = { export const All: Story = {
name: "Dialog", name: "Dialog",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader <StoryPageHeader title="Dialog" />
title="Dialog"
/>
<StorySection title="Overview">
<Basic />
</StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<div className="flex flex-wrap gap-3"> <SpecimenGrid
<LongContent /> states={[
<WideContent /> { id: "basic", label: "Basic", children: <Basic /> },
<BodyScroll /> { id: "long-content", label: "Long Content", children: <LongContent /> },
</div> { id: "wide-content", label: "Wide Content", children: <WideContent /> },
{ id: "body-scroll", label: "Body Scroll", children: <BodyScroll /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<div className="flex flex-wrap gap-3"> <SpecimenGrid
<DeleteConfirmation /> states={[
<FormContent /> { id: "delete-confirmation", label: "Delete Confirmation", children: <DeleteConfirmation /> },
</div> { id: "form", label: "Form", children: <FormContent /> },
]}
/>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+24 -8
View File
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
// Usage: preserved native label fixtures do not define a new-screen exception. Use public Label // 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. // 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 { 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 { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Drawer is a modal vaul overlay (Portal). The representative Story opens the real Drawer from its triggers; drag the handle or // Drawer is a modal vaul overlay (Portal). The representative Story opens the real Drawer from its triggers; drag the handle or
@@ -70,7 +71,7 @@ function FormCase({ defaultOpen }: { defaultOpen?: boolean }) {
return ( return (
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={actions}> <Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={actions}>
<div className="grid gap-2"> <div className="grid gap-2">
<label htmlFor="drawer-form-name" className="text-sm font-medium text-muted-foreground"> <label htmlFor="drawer-form-name" className="text-sm font-medium text-foreground">
프로젝트명 프로젝트명
</label> </label>
<Input id="drawer-form-name" size="medium" placeholder="프로젝트명을 입력하세요" /> <Input id="drawer-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
@@ -95,20 +96,35 @@ const meta = { title: "Components/Overlay/Drawer", component: Drawer, parameters
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (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 (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: Overview and Usage are merged into one Usage section (Basic, Form, Long Content).
export const All: Story = { export const All: Story = {
name: "Drawer", name: "Drawer",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Drawer" /> <StoryPageHeader title="Drawer" />
<StorySection title="Overview">
<Basic />
</StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<div className="flex flex-wrap gap-3"> <SpecimenGrid
<FormCase /> states={[
<LongCase /> { id: "basic", label: "Basic", children: <Basic /> },
</div> { id: "form", label: "Form", children: <FormCase /> },
{ id: "long-content", label: "Long Content", children: <LongCase /> },
]}
/>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+1 -1
View File
@@ -87,7 +87,7 @@ export const All: Story = {
<StoryPageHeader <StoryPageHeader
title="Dropdown Menu" title="Dropdown Menu"
/> />
<StorySection title="Overview"> <StorySection title="Basic">
<Full /> <Full />
</StorySection> </StorySection>
</StoryPage> </StoryPage>
+23 -5
View File
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hm-design/ui" import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Hover Card is a Radix overlay (Portal) for a richer preview than Tooltip. The representative Story shows real triggers // Hover Card is a Radix overlay (Portal) for a richer preview than Tooltip. The representative Story shows real triggers
@@ -48,17 +49,34 @@ const meta = { title: "Components/Overlay/Hover Card", component: HoverCard, par
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (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 (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: Overview and Usage are merged into one Variants section (Basic, Long Content).
export const All: Story = { export const All: Story = {
name: "Hover Card", name: "Hover Card",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Hover Card" /> <StoryPageHeader title="Hover Card" />
<StorySection title="Overview"> <StorySection title="Variants">
<Preview /> <SpecimenGrid
</StorySection> states={[
<StorySection title="Usage"> { id: "basic", label: "Basic", children: <Preview /> },
<LongContent /> { id: "long-content", label: "Long Content", children: <LongContent /> },
]}
/>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+4 -3
View File
@@ -9,8 +9,9 @@ import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-pag
// separate Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts). // separate Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts).
// //
// Label, Required star and Helper/Error message are not part of the Input component. Specimen below composes them with // Label, Required star and Helper/Error message are not part of the Input component. Specimen below composes them with
// plain elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 and Helper 12/18/400 in the // plain elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 in the default text color
// secondary text color, Error message 11/18 in the destructive color, field gap 8. // (Disabled keeps the secondary color at 0.6) and Helper 12/18/400 in the secondary text color, Error message 11/18
// in the destructive color, field gap 8.
function Specimen({ function Specimen({
label = "Project name", label = "Project name",
@@ -25,7 +26,7 @@ function Specimen({
const messageId = `${fieldId}-message` const messageId = `${fieldId}-message`
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<label htmlFor={fieldId} className={`justify-self-start text-sm font-medium text-muted-foreground${input.disabled ? " opacity-60" : ""}`}> <label htmlFor={fieldId} className={`justify-self-start text-sm font-medium ${input.disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}>
{label} {label}
{required ? ( {required ? (
<span aria-hidden="true" className="text-destructive"> <span aria-hidden="true" className="text-destructive">
+37 -16
View File
@@ -1,13 +1,34 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Input, Label, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@hm-design/ui" import { Input, Label, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Label has no standalone ui_viz spec. Its typography and color are the Field Label role owned by Input / Textarea // Label has no standalone ui_viz spec. Its typography and color are the Field Label role owned by Input / Textarea
// (14/20/500, color.fg.secondary). Required star and messages are composed here as a consumer would (not part of Label). // (14/20/500, color.fg.secondary). Required star and messages are composed here as a consumer would (not part of Label).
// 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. The Disabled example keeps the
// shared secondary color (at 0.5 through `peer-disabled`). State titles are untouched.
const fieldLabel = "[--hm-label-text:var(--foreground)]"
// Representative-page layout (Storybook presentation only): 3 equal columns on PC (iframe width 1000px and up), shrinking to
// fit below that (240px minimum). 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 3 specimens keeps the
// same column width and leaves the remaining tracks empty.
function SpecimenGrid({ states }: { states: BoardState[] }) {
return (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
const meta = { const meta = {
title: "Components/Form/Label", title: "Components/Form/Label",
// `internal` keeps it out of the sidebar by default; Story IDs and direct URLs stay valid.
tags: ["internal"],
component: Label, component: Label,
parameters: { parameters: {
layout: "padded", layout: "padded",
@@ -28,25 +49,19 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Label" /> <StoryPageHeader title="Label" />
<StorySection title="Overview"> <StorySection title="States">
<StateBoard <SpecimenGrid
states={[ states={[
{ {
id: "default", id: "default",
label: "Default", label: "Default",
children: ( children: (
<div className="grid max-w-sm gap-2"> <div className="grid max-w-sm gap-2">
<Label htmlFor="label-default">Project name</Label> <Label htmlFor="label-default" className={fieldLabel}>Project name</Label>
<Input id="label-default" size="medium" placeholder="Enter a project name" /> <Input id="label-default" size="medium" placeholder="Enter a project name" />
</div> </div>
), ),
}, },
]}
/>
</StorySection>
<StorySection title="States">
<StateBoard
states={[
{ {
id: "disabled", id: "disabled",
label: "Disabled", label: "Disabled",
@@ -63,20 +78,26 @@ export const All: Story = {
/> />
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<div className="mb-4 grid max-w-sm gap-2"> <SpecimenGrid
<Label htmlFor="label-select">부서 선택</Label> states={[
{
id: "select",
label: "Select",
children: (
<div className="grid max-w-sm gap-2">
<Label htmlFor="label-select" className={fieldLabel}>부서 선택</Label>
<Select><SelectTrigger id="label-select" size="medium"><SelectValue placeholder="부서를 선택하세요" /></SelectTrigger> <Select><SelectTrigger id="label-select" size="medium"><SelectValue placeholder="부서를 선택하세요" /></SelectTrigger>
<SelectContent><SelectItem value="design">디자인</SelectItem><SelectItem value="engineering">개발</SelectItem></SelectContent> <SelectContent><SelectItem value="design">디자인</SelectItem><SelectItem value="engineering">개발</SelectItem></SelectContent>
</Select> </Select>
</div> </div>
<StateBoard ),
states={[ },
{ {
id: "required", id: "required",
label: "Required", label: "Required",
children: ( children: (
<div className="grid max-w-sm gap-2"> <div className="grid max-w-sm gap-2">
<Label htmlFor="label-required"> <Label htmlFor="label-required" className={fieldLabel}>
Project name Project name
<span aria-hidden="true" className="text-destructive"> <span aria-hidden="true" className="text-destructive">
* *
@@ -91,7 +112,7 @@ export const All: Story = {
label: "Long Label", label: "Long Label",
children: ( children: (
<div className="grid max-w-[220px] gap-2"> <div className="grid max-w-[220px] gap-2">
<Label htmlFor="label-long">A very long label text that wraps to multiple lines</Label> <Label htmlFor="label-long" className={fieldLabel}>A very long label text that wraps to multiple lines</Label>
<Input id="label-long" size="medium" /> <Input id="label-long" size="medium" />
</div> </div>
), ),
+3 -3
View File
@@ -97,10 +97,10 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Menubar" /> <StoryPageHeader title="Menubar" />
<StorySection title="Overview"> {/* The section is titled Basic (was Overview). The former `minHeight: 384` reserved room for the open menus; they render in a
<div style={{ minHeight: 384 }}> portal over the page (with collision handling), nothing follows this section, so the fixed height is not needed. */}
<StorySection title="Basic">
<Specimen /> <Specimen />
</div>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+12 -17
View File
@@ -83,10 +83,13 @@ function Specimen({ viewport = true }: { viewport?: boolean }) {
// Size specimens to their review area (including the panel's 8px padding + 1px border on each side). // Size specimens to their review area (including the panel's 8px padding + 1px border on each side).
// Mobile uses the content's natural minimum width instead of a fixed desktop specimen width. // Mobile uses the content's natural minimum width instead of a fixed desktop specimen width.
const frame = (node: ReactNode) => ( // `minHeight: 256` reserves room for the open panel (about 232px below the menu bar) so it never covers what follows.
// `compact` (representative page only) drops that reservation on PC (iframe width 1000px and up), where the two examples sit side by
// side and nothing is below them; in the one-column layout below 1000px the 256px stays so an open menu does not cover the next example.
const frame = (node: ReactNode, compact = false) => (
<div <div
style={{ minHeight: 256, width: "100%", containerType: "inline-size" }} style={{ width: "100%", containerType: "inline-size", ...(compact ? {} : { minHeight: 256 }) }}
className="max-md:[&_[data-viewport=false]_[data-slot=navigation-menu-content]]:absolute" className={`max-md:[&_[data-viewport=false]_[data-slot=navigation-menu-content]]:absolute${compact ? " max-[999px]:min-h-64" : ""}`}
> >
{node} {node}
</div> </div>
@@ -102,21 +105,13 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Navigation Menu" /> <StoryPageHeader title="Navigation Menu" />
<StorySection title="Overview"> {/* Overview is merged into Variants (Viewport is its first item). Two equal columns on PC (iframe width 1000px and up), one
<StateBoard column below that; each specimen sits in its own single-column StateBoard (min larger than any column). */}
min={520}
states={[
{ id: "viewport", label: "Viewport", children: frame(<Specimen />) },
]}
/>
</StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<StateBoard <div className="grid items-start gap-6 min-[1000px]:grid-cols-2">
min={520} <StateBoard min={10000} states={[{ id: "viewport", label: "Viewport", children: frame(<Specimen />, true) }]} />
states={[ <StateBoard min={10000} states={[{ id: "no-viewport", label: "No viewport", children: frame(<Specimen viewport={false} />, true) }]} />
{ id: "no-viewport", label: "No viewport", children: frame(<Specimen viewport={false} />) }, </div>
]}
/>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+17 -7
View File
@@ -79,20 +79,30 @@ const meta = { title: "Components/Navigation/Pagination", component: Pagination,
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): one Variants section, 2 equal columns x 2 rows on PC (iframe width
<StateBoard min={420} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: wrap(s.node) }))} /> // 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`;
// `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"
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page. Overview / States / Usage are merged into Variants
// (First page, Middle page, Last page, Long range).
export const All: Story = { export const All: Story = {
name: "Pagination", name: "Pagination",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Pagination" /> <StoryPageHeader title="Pagination" />
<StorySection title="Overview">{board("middle")}</StorySection> <StorySection title="Variants">
<StorySection title="States">{board("first", "last")}</StorySection> {/* Long range is 447px wide (20 pages, 7 items), so a column must be at least that: 16px column gap and the 2-column switch at
<StorySection title="Usage">{board("ellipsis")}</StorySection> iframe width 1030px give 449px columns at 1280px; below that it is one column (460px minimum) instead of a wrapped "Next". */}
<div className="grid items-start gap-x-4 gap-y-8 [grid-template-columns:repeat(auto-fill,minmax(min(100%,460px),1fr))] min-[1030px]:grid-cols-2">
{["first", "middle", "last", "ellipsis"].map((id) => {
const spec = sections.find((section) => section.id === id)!
return <StateBoard key={id} min={10000} states={[{ id: spec.id, label: spec.label, children: <div className={cellAlignLeft}>{spec.node}</div> }]} />
})}
</div>
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+28 -12
View File
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button, Input, Popover, PopoverClose, PopoverContent, PopoverTrigger } from "@hm-design/ui" import { Button, Input, Popover, PopoverClose, PopoverContent, PopoverTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Popover is a non-modal Radix overlay (Portal). The representative Story opens the real panels from their triggers; // Popover is a non-modal Radix overlay (Portal). The representative Story opens the real panels from their triggers;
@@ -33,7 +34,7 @@ function FormPanel() {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start" aria-label="프로젝트 이름 변경"> <PopoverContent align="start" aria-label="프로젝트 이름 변경">
<div className="grid gap-3"> <div className="grid gap-3">
<label htmlFor="popover-name" className="text-sm font-medium text-muted-foreground"> <label htmlFor="popover-name" className="text-sm font-medium text-foreground">
프로젝트명 프로젝트명
</label> </label>
<Input id="popover-name" size="medium" placeholder="프로젝트명을 입력하세요" /> <Input id="popover-name" size="medium" placeholder="프로젝트명을 입력하세요" />
@@ -55,11 +56,9 @@ function FormPanel() {
) )
} }
function Aligned() { function Aligned({ align }: { align: "start" | "center" | "end" }) {
return ( return (
<div className="flex w-full max-w-md justify-between gap-3"> <Popover>
{(["start", "center", "end"] as const).map((align) => (
<Popover key={align}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="primary" appearance="outline" size="sm"> <Button variant="primary" appearance="outline" size="sm">
{`align ${align}`} {`align ${align}`}
@@ -69,8 +68,6 @@ function Aligned() {
<p>{`align="${align}"`}</p> <p>{`align="${align}"`}</p>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
))}
</div>
) )
} }
@@ -78,20 +75,39 @@ const meta = { title: "Components/Overlay/Popover", component: Popover, paramete
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (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 (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: Overview is merged into Variants (Basic is its first item); each align trigger sits in its own column.
export const All: Story = { export const All: Story = {
name: "Popover", name: "Popover",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Popover" /> <StoryPageHeader title="Popover" />
<StorySection title="Overview">
<Info />
</StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<Aligned /> <SpecimenGrid
states={[
{ id: "basic", label: "Basic", children: <Info /> },
{ id: "align-start", label: "Align Start", children: <Aligned align="start" /> },
{ id: "align-center", label: "Align Center", children: <Aligned align="center" /> },
{ id: "align-end", label: "Align End", children: <Aligned align="end" /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<FormPanel /> <SpecimenGrid states={[{ id: "form", label: "Form", children: <FormPanel /> }]} />
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+29 -5
View File
@@ -49,20 +49,44 @@ const meta = { title: "Components/Data & Display/Progress", component: Progress,
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} /> // (240px 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.
const specimenColumns = {
2: "min-[1000px]:grid-cols-2",
3: "min-[1000px]:grid-cols-3",
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}>
{ids.map((id) => {
const s = sections.find((section) => section.id === id)!
return (
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
</div>
) )
})}
</div>
)
}
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// Representative Story: Overview (50%) is merged into States (0%, 50%, 100%); max = 200 and Interactive stay under Usage. 3 columns on PC.
export const All: Story = { export const All: Story = {
name: "Progress", name: "Progress",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Progress" /> <StoryPageHeader title="Progress" />
<StorySection title="Overview">{board("middle")}</StorySection> <StorySection title="States">
<StorySection title="States">{board("zero", "full")}</StorySection> <SpecimenGrid ids={["zero", "middle", "full"]} columns={3} />
<StorySection title="Usage">{board("custom-maximum", "interactive")}</StorySection> </StorySection>
<StorySection title="Usage">
<SpecimenGrid ids={["custom-maximum", "interactive"]} columns={3} />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+42 -9
View File
@@ -1,10 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Label, RadioGroup, RadioGroupItem } from "@hm-design/ui" import { Label, RadioGroup, RadioGroupItem } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import type { CSSProperties } from "react"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Current HM Radio Group. ui-viz has no Radio spec, so it was reference only. // Current HM Radio Group. 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 (
<div
className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--sb-col-min)),1fr))] ${specimenColumns[columns]}`}
style={{ "--sb-col-min": `${min}px` } as CSSProperties}
>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
function Group({ function Group({
prefix, prefix,
defaultValue = "a", defaultValue = "a",
@@ -28,7 +55,7 @@ function Group({
{["a", "b", "c"].map((v) => ( {["a", "b", "c"].map((v) => (
<div key={v} className="flex items-center gap-2"> <div key={v} className="flex items-center gap-2">
<RadioGroupItem value={v} id={`${prefix}-${v}`} disabled={disabledItem === v} aria-invalid={invalid ? true : undefined} data-sb-hover={hoverItem === v ? "" : undefined} /> <RadioGroupItem value={v} id={`${prefix}-${v}`} disabled={disabledItem === v} aria-invalid={invalid ? true : undefined} data-sb-hover={hoverItem === v ? "" : undefined} />
<Label htmlFor={`${prefix}-${v}`}>Option {v.toUpperCase()}</Label> <Label htmlFor={`${prefix}-${v}`} className={optionLabel}>Option {v.toUpperCase()}</Label>
</div> </div>
))} ))}
</RadioGroup> </RadioGroup>
@@ -57,15 +84,20 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Radio Group" /> <StoryPageHeader title="Radio Group" />
<StorySection title="Overview"> {/* Overview is merged into Variants: its Vertical example is the first Variants item. */}
<StateBoard min={240} states={[{ id: "vertical", label: "Vertical", children: <Group prefix="v" defaultValue="b" /> }]} />
</StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<StateBoard min={240} states={[{ id: "horizontal", label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> }]} /> <SpecimenGrid
columns={2}
min={320}
states={[
{ id: "vertical", label: "Vertical", children: <Group prefix="v" defaultValue="b" /> },
{ id: "horizontal", label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection title="States">
<StateBoard <SpecimenGrid
min={240} columns={4}
states={[ states={[
{ id: "hover", label: "Hover", children: <Group prefix="hv" defaultValue="" hoverItem="a" /> }, { id: "hover", label: "Hover", children: <Group prefix="hv" defaultValue="" hoverItem="a" /> },
{ id: "focus", label: "Focus", children: <Group prefix="f" /> }, { id: "focus", label: "Focus", children: <Group prefix="f" /> },
@@ -75,7 +107,8 @@ export const All: Story = {
/> />
</StorySection> </StorySection>
<StorySection title="Validation"> <StorySection title="Validation">
<StateBoard min={240} states={[{ id: "error", label: "Error", children: <Group prefix="e" invalid /> }]} /> {/* Same 4-column track as States, so the Error example keeps the width of one States column. */}
<SpecimenGrid columns={4} states={[{ id: "error", label: "Error", children: <Group prefix="e" invalid /> }]} />
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+18 -6
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from "react" import type { ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@hm-design/ui" import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// The representative Story shows real, draggable layouts; the same specimens also exist as Stories tagged `verification` (hidden // The representative Story shows real, draggable layouts; the same specimens also exist as Stories tagged `verification` (hidden
@@ -99,8 +99,21 @@ const meta = { title: "Components/Disclosure/Resizable", component: ResizablePan
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), one column
<StateBoard min={580} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} /> // 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 and unused columns stay empty. The frames keep their own size (max 560px wide).
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
return (
<div className={`grid grid-cols-1 items-start gap-6 ${cols === 3 ? "min-[1000px]:grid-cols-3" : "min-[1000px]:grid-cols-2"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
const board = (cols: 2 | 3, ...ids: string[]) => (
<StateGrid cols={cols} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
) )
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
@@ -112,9 +125,8 @@ export const All: Story = {
<StoryPageHeader <StoryPageHeader
title="Resizable" title="Resizable"
/> />
<StorySection title="Overview">{board("horizontal")}</StorySection> <StorySection title="Variants">{board(3, "horizontal", "handle", "vertical")}</StorySection>
<StorySection title="Variants">{board("vertical", "handle")}</StorySection> <StorySection title="Usage">{board(3, "minmax", "collapsible")}</StorySection>
<StorySection title="Usage">{board("minmax", "collapsible")}</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+29 -5
View File
@@ -77,20 +77,44 @@ const meta = { title: "Components/Data & Display/Scroll Area", component: Scroll
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} /> // (300px 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.
const specimenColumns = {
2: "min-[1000px]:grid-cols-2",
3: "min-[1000px]:grid-cols-3",
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] ${specimenColumns[columns]}`}>
{ids.map((id) => {
const s = sections.find((section) => section.id === id)!
return (
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
</div>
) )
})}
</div>
)
}
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// Representative Story: Overview (Vertical) is merged into Variants (Vertical, Horizontal, Both axes); Table-like stays under Usage. 3 columns on PC.
export const All: Story = { export const All: Story = {
name: "Scroll Area", name: "Scroll Area",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Scroll Area" /> <StoryPageHeader title="Scroll Area" />
<StorySection title="Overview">{board("vertical")}</StorySection> <StorySection title="Variants">
<StorySection title="Variants">{board("horizontal", "both")}</StorySection> <SpecimenGrid ids={["vertical", "horizontal", "both"]} columns={3} />
<StorySection title="Usage">{board("table-like")}</StorySection> </StorySection>
<StorySection title="Usage">
<SpecimenGrid ids={["table-like"]} columns={3} />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+33 -8
View File
@@ -1,7 +1,7 @@
import { useId, type ReactNode } from "react" import { useId, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type SelectSize } from "@hm-design/ui" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type SelectSize } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows ui_viz (implementations/web/storybook/Select.stories.jsx and SelectSizes.stories.jsx): // Story structure follows ui_viz (implementations/web/storybook/Select.stories.jsx and SelectSizes.stories.jsx):
@@ -42,9 +42,11 @@ type SpecimenProps = {
function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a status", size, defaultValue, defaultOpen, disabled, "aria-invalid": ariaInvalid, options = statusOptions }: SpecimenProps) { function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a status", size, defaultValue, defaultOpen, disabled, "aria-invalid": ariaInvalid, options = statusOptions }: SpecimenProps) {
const id = useId() const id = useId()
// A Label next to an enabled Select uses the default text color; disabled keeps the secondary color at 0.6.
const labelTone = disabled ? "text-muted-foreground opacity-60" : "text-foreground"
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<label htmlFor={id} className={`justify-self-start text-sm font-medium text-muted-foreground${disabled ? " opacity-60" : ""}`}> <label htmlFor={id} className={`justify-self-start text-sm font-medium ${labelTone}`}>
{label} {label}
</label> </label>
<Select defaultValue={defaultValue} defaultOpen={defaultOpen} disabled={disabled}> <Select defaultValue={defaultValue} defaultOpen={defaultOpen} disabled={disabled}>
@@ -109,6 +111,23 @@ function SizeComparison({ showSpec = true }: { showSpec?: boolean }) {
) )
} }
// Representative-page layout (Storybook presentation only): 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 track. Full class names are listed so Tailwind can see them.
const specimenColumns = {
3: "min-[1000px]:grid-cols-3",
} as const
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: keyof typeof specimenColumns }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
function DisabledStates() { function DisabledStates() {
return ( return (
<div className="grid max-w-[480px] gap-6"> <div className="grid max-w-[480px] gap-6">
@@ -157,11 +176,16 @@ export const All: Story = {
<StoryPage> <StoryPage>
<StoryPageHeader title="Select" /> <StoryPageHeader title="Select" />
<StorySection title="Sizes"> <StorySection title="Sizes">
<SizeComparison showSpec={false} /> {/* Same 3-column track as States / Validation: Medium and Large take the first two columns, the third stays empty. */}
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{sizes.map((spec) => (
<SizeCard key={spec.size} spec={spec} showSpec={false} />
))}
</div>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection title="States">
<StateBoard <SpecimenGrid
fixed columns={3}
states={[ states={[
{ id: "selected", label: "Selected", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" /> }, { id: "selected", label: "Selected", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" /> },
{ id: "disabled", label: "Disabled", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" disabled /> }, { id: "disabled", label: "Disabled", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" disabled /> },
@@ -169,9 +193,10 @@ export const All: Story = {
]} ]}
/> />
</StorySection> </StorySection>
<StorySection title="Invalid / Focus"> <StorySection title="Validation">
<StateBoard {/* Same 3-column track as States: the two specimens take the first two columns (same Select width) and the third stays empty. */}
fixed <SpecimenGrid
columns={3}
states={[ states={[
{ id: "invalid", label: "Invalid", children: <SelectSpecimen size="medium" aria-invalid label="Status" defaultValue="active" /> }, { id: "invalid", label: "Invalid", children: <SelectSpecimen size="medium" aria-invalid label="Status" defaultValue="active" /> },
{ id: "invalid-disabled", label: "Invalid · Disabled", children: <SelectSpecimen size="medium" aria-invalid disabled label="Status" /> }, { id: "invalid-disabled", label: "Invalid · Disabled", children: <SelectSpecimen size="medium" aria-invalid disabled label="Status" /> },
+24 -11
View File
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Separator } from "@hm-design/ui" import { Separator } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
const meta = { const meta = {
@@ -20,15 +20,35 @@ const meta = {
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (240px 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.
const specimenColumns = {
2: "min-[1000px]:grid-cols-2",
3: "min-[1000px]:grid-cols-3",
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: 2 | 3 | 4 }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
// Representative Story: Overview (Horizontal) and Variants (Vertical, Semantic) are merged into one Variants section. 3 columns on PC.
export const All: Story = { export const All: Story = {
name: "Separator", name: "Separator",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Separator" /> <StoryPageHeader title="Separator" />
<StorySection title="Overview"> <StorySection title="Variants">
<StateBoard <SpecimenGrid
min={280} columns={3}
states={[ states={[
{ {
id: "horizontal", id: "horizontal",
@@ -41,13 +61,6 @@ export const All: Story = {
</div> </div>
), ),
}, },
]}
/>
</StorySection>
<StorySection title="Variants">
<StateBoard
min={280}
states={[
{ {
id: "vertical", id: "vertical",
label: "Vertical", label: "Vertical",
+32 -16
View File
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
// Usage: preserved native label fixtures do not define a new-screen exception. Use public Label // 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. // for supported fields (docs/components/label.md §5); copy only actions requested by the screen.
import { Button, Input, Sheet, SheetBody, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@hm-design/ui" import { Button, Input, Sheet, SheetBody, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Sheet is a modal Radix overlay (Portal), so the representative Story is a row of triggers that open the real Sheet for each // Sheet is a modal Radix overlay (Portal), so the representative Story is a row of triggers that open the real Sheet for each
@@ -68,7 +69,7 @@ function FormCase({ defaultOpen }: { defaultOpen?: boolean }) {
return ( return (
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보"> <Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보">
<div className="grid gap-2"> <div className="grid gap-2">
<label htmlFor="sheet-form-name" className="text-sm font-medium text-muted-foreground"> <label htmlFor="sheet-form-name" className="text-sm font-medium text-foreground">
프로젝트명 프로젝트명
</label> </label>
<Input id="sheet-form-name" size="medium" placeholder="프로젝트명을 입력하세요" /> <Input id="sheet-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
@@ -93,29 +94,44 @@ const meta = { title: "Components/Overlay/Sheet", component: Sheet, parameters:
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (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 (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: the common Story page. Overview is merged into Variants (Right is its first item).
export const All: Story = { export const All: Story = {
name: "Sheet", name: "Sheet",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader <StoryPageHeader title="Sheet" />
title="Sheet"
/>
<StorySection title="Overview">
<SideCase side="right" />
</StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<div className="flex flex-wrap gap-3"> <SpecimenGrid
<SideCase side="left" /> states={[
<SideCase side="top" /> { id: "right", label: "Right", children: <SideCase side="right" /> },
<SideCase side="bottom" /> { id: "left", label: "Left", children: <SideCase side="left" /> },
</div> { id: "top", label: "Top", children: <SideCase side="top" /> },
{ id: "bottom", label: "Bottom", children: <SideCase side="bottom" /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<div className="flex flex-wrap gap-3"> <SpecimenGrid
<FormCase /> states={[
<LongCase /> { id: "form", label: "Form", children: <FormCase /> },
</div> { id: "long-content", label: "Long Content", children: <LongCase /> },
]}
/>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+26 -4
View File
@@ -53,19 +53,41 @@ const meta = { title: "Components/Data & Display/Skeleton", component: Skeleton,
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} /> // (240px 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.
const specimenColumns = {
2: "min-[1000px]:grid-cols-2",
3: "min-[1000px]:grid-cols-3",
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}>
{ids.map((id) => {
const s = sections.find((section) => section.id === id)!
return (
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
</div>
) )
})}
</div>
)
}
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// Representative Story: Overview / Usage are merged into one Variants section (Text, Avatar + text, Card / list loading). 3 columns on PC.
export const All: Story = { export const All: Story = {
name: "Skeleton", name: "Skeleton",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Skeleton" /> <StoryPageHeader title="Skeleton" />
<StorySection title="Overview">{board("text")}</StorySection> <StorySection title="Variants">
<StorySection title="Usage">{board("avatar", "card")}</StorySection> <SpecimenGrid ids={["text", "avatar", "card"]} columns={3} />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+23 -8
View File
@@ -12,7 +12,7 @@ function Labeled({ label, children, valueText }: { label: string; children: Reac
return ( return (
<div className="grid gap-3"> <div className="grid gap-3">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="font-medium">{label}</span> <span className="font-medium text-foreground">{label}</span>
<span className="text-muted-foreground" data-testid="value">{valueText}</span> <span className="text-muted-foreground" data-testid="value">{valueText}</span>
</div> </div>
{children} {children}
@@ -32,8 +32,8 @@ function Single() {
function Range() { function Range() {
const [value, setValue] = useState([25, 75]) const [value, setValue] = useState([25, 75])
return ( return (
<Labeled label="가격 범위" valueText={`${value[0]} – ${value[1]}`}> <Labeled label="범위" valueText={`${value[0]} – ${value[1]}`}>
<Slider aria-label="가격 범위" value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} /> <Slider aria-label="범위"value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} />
</Labeled> </Labeled>
) )
} }
@@ -71,20 +71,35 @@ const meta = { title: "Components/Form/Slider", component: Slider, parameters: {
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): 4 equal columns on PC (iframe width 1000px and up), shrinking to
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} /> // fit below that (200px minimum per column). Each specimen sits in its own single-column StateBoard (min larger than any
// column), so StateBoard's auto-fill grid cannot leave an empty or stretched track. A section with fewer than 4 specimens
// keeps the same column width and leaves the remaining tracks empty.
function SpecimenGrid({ ids }: { ids: string[] }) {
return (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4">
{ids.map((id) => {
const s = sections.find((section) => section.id === id)!
return <StateBoard key={id} min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
})}
</div>
) )
}
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// Overview is merged into Variants (Single is its first item). `Vertical` centers its Slider in its column.
export const All: Story = { export const All: Story = {
name: "Slider", name: "Slider",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Slider" /> <StoryPageHeader title="Slider" />
<StorySection title="Overview">{board("single")}</StorySection> <StorySection title="Variants">
<StorySection title="Variants">{board("range", "step", "vertical")}</StorySection> <SpecimenGrid ids={["single", "range", "step", "vertical"]} />
<StorySection title="States">{board("disabled")}</StorySection> </StorySection>
<StorySection title="States">
<SpecimenGrid ids={["disabled"]} />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+1 -1
View File
@@ -22,7 +22,7 @@ function Trigger({ onClick, children }: { onClick: () => void; children: ReactNo
function Board({ ids }: { ids: string[] }) { function Board({ ids }: { ids: string[] }) {
return ( return (
<StateBoard <StateBoard
min={220} min={360}
states={[ states={[
{ {
id: "types", id: "types",
+27 -2
View File
@@ -1,5 +1,5 @@
/* Storybook presentation only. Common page frame for representative Stories; all selectors stay inside .sb-page. /* Storybook presentation only. Common page frame for representative Stories; all selectors stay inside .sb-page.
Inner (fluid; 16px each side up to 480px, growing to 60px at 768px; vertical gutter 16–40) and title (kicker 12 / title 28·36·700 / description, underlined) reuse Foundation / Color. Inner (fluid; 16px each side up to 480px, growing to 60px at 768px; vertical gutter 16–40) and title (kicker 12 / title 28·36·700 / description, no divider below) reuse Foundation / Color.
Section reuses the Center Graphic / 교량 split: heading + description, a divider, then the specimen. */ Section reuses the Center Graphic / 교량 split: heading + description, a divider, then the specimen. */
.sb-page { .sb-page {
--sb-page-gutter: clamp(16px, 3vw, 40px); --sb-page-gutter: clamp(16px, 3vw, 40px);
@@ -22,7 +22,6 @@
.sb-page .sb-page-header { .sb-page .sb-page-header {
padding-bottom: 24px; padding-bottom: 24px;
border-bottom: 1px solid var(--hm-sem-stroke-input-default);
font-size: var(--hm-label-font-size); font-size: var(--hm-label-font-size);
line-height: var(--hm-label-line-height); line-height: var(--hm-label-line-height);
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -62,3 +61,29 @@
font-weight: var(--hm-input-font-weight); font-weight: var(--hm-input-font-weight);
} }
.sb-page .sb-page-section-body { min-width: 0; } .sb-page .sb-page-section-body { min-width: 0; }
/* Notice above a page title (StoryNotice). Reuses .sb-page / .sb-page-inner (width and side margin are not redefined).
The frame drops its bottom gutter and pulls the next page up so the notice sits 24px above the title block's own top gutter. */
.sb-page.sb-page-notice {
padding-bottom: 0;
margin-bottom: calc(24px - var(--sb-page-gutter));
}
.sb-page .sb-notice {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0;
font-size: var(--hm-label-font-size);
line-height: var(--hm-label-line-height);
font-weight: var(--hm-input-font-weight);
color: var(--hm-sem-fg-primary);
overflow-wrap: anywhere;
}
.sb-page .sb-notice-icon {
flex: none;
width: 20px;
height: 20px;
/* centers the icon on the first text line even when the line height differs from 20px */
margin-block: calc((var(--hm-label-line-height) - 20px) / 2);
}
.sb-page .sb-notice-text { min-width: 0; }
+20
View File
@@ -25,6 +25,26 @@ export function StoryPageHeader({ kicker, title, description }: { kicker?: strin
) )
} }
// Notice row (icon + text) shown above a Story page title. Reusable by any Story group through a decorator
// (e.g. src/sw/sw-story-notice.tsx); nothing renders it unless a group opts in. It owns a page frame whose Inner is the
// same as StoryPage's, and cancels its own bottom gutter so the page title below stays where it was.
// Icon: Group 1171290812.svg (circle + "!"), colors via currentColor.
export function StoryNotice({ children }: { children: ReactNode }) {
return (
<div className="sb-page sb-page-notice" data-story-notice="">
<div className="sb-page-inner">
<p className="sb-notice">
<svg className="sb-notice-icon" width="20" height="20" viewBox="0 0 29 29" fill="none" aria-hidden="true" focusable="false">
<path d="M15.7415 6L15.5383 17.3984H13.3743L13.1711 6H15.7415ZM14.4602 22.1484C14.0279 22.1484 13.6581 21.9974 13.3508 21.6953C13.0435 21.388 12.8925 21.0182 12.8977 20.5859C12.8925 20.1589 13.0435 19.7943 13.3508 19.4922C13.6581 19.1849 14.0279 19.0312 14.4602 19.0312C14.8821 19.0312 15.2467 19.1849 15.554 19.4922C15.8612 19.7943 16.0175 20.1589 16.0227 20.5859C16.0175 20.8724 15.942 21.1354 15.7961 21.375C15.6555 21.6094 15.468 21.7969 15.2336 21.9375C14.9993 22.0781 14.7415 22.1484 14.4602 22.1484Z" fill="currentColor" />
<circle cx="14.5" cy="14.5" r="13.5" stroke="currentColor" strokeWidth="2" />
</svg>
<span className="sb-notice-text">{children}</span>
</p>
</div>
</div>
)
}
// Headings and specimens share StoryPage's single Inner. Wide specimens own their local scroll frame. // Headings and specimens share StoryPage's single Inner. Wide specimens own their local scroll frame.
export function StorySection({ export function StorySection({
title, title,
+22 -12
View File
@@ -1,15 +1,33 @@
import { useId, type ReactNode } from "react" import { useId, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Label, Switch } from "@hm-design/ui" import { Label, Switch } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// 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 }) { function Option({ label, children }: { label: string; children: (id: string) => ReactNode }) {
const id = useId() const id = useId()
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{children(id)} {children(id)}
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id} className={optionLabel}>{label}</Label>
</div>
)
}
// 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 (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,150px),1fr))] min-[1000px]:grid-cols-5">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div> </div>
) )
} }
@@ -36,19 +54,11 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Switch" /> <StoryPageHeader title="Switch" />
<StorySection title="Overview"> <StorySection title="States">
<StateBoard <StateGrid
min={240}
states={[ states={[
{ id: "off", label: "Off", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> }, { id: "off", label: "Off", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
{ id: "on", label: "On", children: <Option label="Option">{(id) => <Switch id={id} defaultChecked />}</Option> }, { id: "on", label: "On", children: <Option label="Option">{(id) => <Switch id={id} defaultChecked />}</Option> },
]}
/>
</StorySection>
<StorySection title="States">
<StateBoard
min={240}
states={[
{ {
id: "hover", id: "hover",
label: "Hover", label: "Hover",
+34 -6
View File
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from "react" import { useState, type CSSProperties, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Badge, Checkbox, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@hm-design/ui" import { Badge, Checkbox, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard } from "@/components/story-board"
@@ -144,20 +144,48 @@ const meta = { title: "Components/Data & Display/Table", component: Table, param
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
<StateBoard min={460} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, wide: true, children: box(s.node) }))} /> // (400px 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 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
// and long text wraps there. Horizontal overflow keeps its inline `minWidth: 1100`, so it still overflows and scrolls inside its box.
const specimenColumns = {
2: "min-[1000px]:grid-cols-2",
3: "min-[1000px]:grid-cols-3",
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,400px),1fr))] ${specimenColumns[columns]}`}>
{ids.map((id) => {
const s = sections.find((section) => section.id === id)!
return (
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.id === "wide" ? box(s.node) : <div style={{ "--hm-table-min-width": "0px" } as CSSProperties}>{box(s.node)}</div> }]} />
</div>
) )
})}
</div>
)
}
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// Representative Story: Overview / States / Usage are regrouped into Variants (Basic, Selected row) and Usage (Long text,
// Horizontal overflow), two equal columns each on PC.
export const All: Story = { export const All: Story = {
name: "Table", name: "Table",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Table" /> <StoryPageHeader title="Table" />
<StorySection title="Overview">{board("basic")}</StorySection> <StorySection title="Variants">
<StorySection title="States">{board("selected")}</StorySection> <SpecimenGrid ids={["basic", "selected"]} columns={2} />
<StorySection title="Usage">{board("long", "wide")}</StorySection> </StorySection>
<StorySection title="Usage">
<SpecimenGrid ids={["long", "wide"]} columns={2} />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+27 -26
View File
@@ -1,11 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { useId, useState, type ComponentProps, type CSSProperties } from "react" import { useId, useState, type ComponentProps, type CSSProperties, type ReactNode } from "react"
import { import {
Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea, Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea,
Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
Tabs, TabsContent, TabsList, TabsTrigger, Tabs, TabsContent, TabsList, TabsTrigger,
} from "@hm-design/ui" } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Representative "Tabs" Story shows default Primary, explicit API comparisons and Secondary; Legacy stays verification-only. // Representative "Tabs" Story shows default Primary, explicit API comparisons and Secondary; Legacy stays verification-only.
@@ -127,6 +127,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).
const TwoColumns = ({ children }: { children: ReactNode }) => (
<div className="grid items-start gap-6 min-[900px]:grid-cols-[repeat(2,minmax(0,1fr))]">{children}</div>
)
const SingleBoard = ({ states }: { states: BoardState[] }) => <StateBoard min={10000} states={states} />
// 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 }) => (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
)
const meta = { const meta = {
title: "Components/Navigation/Tabs", title: "Components/Navigation/Tabs",
component: Tabs, component: Tabs,
@@ -147,34 +159,23 @@ export const All: Story = {
name: "Tabs", name: "Tabs",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> // `py-4!` trims this page's top and bottom padding (30-40px by viewport width) so the page fits a 900px-high PC screen without
// shrinking any component; the important modifier is needed because the shared rule has the same specificity. Section gaps stay 32px.
<StoryPage className="py-4!">
<StoryPageHeader title="Tabs" /> <StoryPageHeader title="Tabs" />
<StorySection title="Overview">
<StateBoard
states={[
{ id: "primary", label: "Primary", children: <Specimen /> },
]}
/>
</StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<StateBoard {/* The former States section (Overflow) is the third Variants item. */}
states={[ <ThreeColumns>
{ id: "secondary", label: "Secondary", children: <Specimen variant="secondary" /> }, <SingleBoard states={[{ id: "primary", label: "Primary", children: <Specimen /> }]} />
]} <SingleBoard states={[{ id: "secondary", label: "Secondary", children: <Specimen variant="secondary" /> }]} />
/> <SingleBoard states={[{ id: "overflow", label: "Overflow", children: <div className="w-full min-w-0"><Specimen count={20} /></div> }]} />
</StorySection> </ThreeColumns>
<StorySection title="States">
<StateBoard
states={[
{ id: "overflow", wide: true, label: "Overflow", children: <div className="w-full min-w-0 max-w-4xl"><Specimen count={20} /></div> },
]}
/>
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<StateBoard states={[ <TwoColumns>
{ id: "usage-card", label: "Card 내부 · Secondary", children: <SurfaceUsage inCard /> }, <SingleBoard states={[{ id: "usage-card", label: "Card 내부 · Secondary", children: <div className="w-full min-w-0 max-w-lg"><SurfaceUsage inCard /></div> }]} />
{ id: "usage-page", label: "페이지 직접 배치 · Primary", children: <SurfaceUsage /> }, <SingleBoard states={[{ id: "usage-page", label: "페이지 직접 배치 · Primary", children: <div className="w-full min-w-0 max-w-lg"><SurfaceUsage /></div> }]} />
]} /> </TwoColumns>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+40 -17
View File
@@ -1,7 +1,7 @@
import { useId, type ComponentProps, type ReactNode } from "react" import { useId, type ComponentProps, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Textarea } from "@hm-design/ui" import { Textarea } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows Input: one representative "Textarea" Story shows every state on one page; the same specimens // Story structure follows Input: one representative "Textarea" Story shows every state on one page; the same specimens
@@ -9,8 +9,8 @@ import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-pag
// ui_viz docs/components/textarea.md §6 Reference Viewer: Default, Hover, Focus, Error, Error + Focus, Readonly, Disabled and Multiline. // ui_viz docs/components/textarea.md §6 Reference Viewer: Default, Hover, Focus, Error, Error + Focus, Readonly, Disabled and Multiline.
// //
// Label, Required star and Helper/Error message are not part of the Textarea component. Specimen composes them with plain // Label, Required star and Helper/Error message are not part of the Textarea component. Specimen composes them with plain
// elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 and Helper 12/18/400 in the secondary // elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 in the default text color (Disabled keeps
// text color, Error message 11/18 in the destructive color, field gap 8. // the secondary color) and Helper 12/18/400 in the secondary text color, Error message 11/18 in the destructive color, field gap 8.
function Specimen({ function Specimen({
label = "Description", label = "Description",
@@ -25,7 +25,7 @@ function Specimen({
const messageId = `${fieldId}-message` const messageId = `${fieldId}-message`
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<label htmlFor={fieldId} className="justify-self-start text-sm font-medium text-muted-foreground"> <label htmlFor={fieldId} className={`justify-self-start text-sm font-medium ${textarea.disabled ? "text-muted-foreground" : "text-foreground"}`}>
{label} {label}
{required ? ( {required ? (
<span aria-hidden="true" className="text-destructive"> <span aria-hidden="true" className="text-destructive">
@@ -49,6 +49,23 @@ function Specimen({
) )
} }
// 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 this grid.
// Full class names are listed so Tailwind can see them.
const specimenColumns = {
4: "min-[1000px]:grid-cols-4",
} as const
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: keyof typeof specimenColumns }) {
return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] ${specimenColumns[columns]}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
)
}
const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div> const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div>
const placeholder = "Enter a description" const placeholder = "Enter a description"
const multiline = "First line\nSecond line keeps its line break.\nThird line." const multiline = "First line\nSecond line keeps its line break.\nThird line."
@@ -86,12 +103,13 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Textarea" /> <StoryPageHeader title="Textarea" />
<StorySection title="Overview"> {/* PC (iframe width 1000px and up): States in 4 equal columns, Validation's 2 specimens on the same column width.
<StateBoard states={[{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> }]} /> Below that the columns shrink to fit (220px minimum, down to 1). Multiline and Resize live in their own hidden Stories. */}
</StorySection>
<StorySection title="States"> <StorySection title="States">
<StateBoard <SpecimenGrid
columns={4}
states={[ states={[
{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> },
{ id: "focus", label: "Focus", children: <Specimen placeholder={placeholder} /> }, { id: "focus", label: "Focus", children: <Specimen placeholder={placeholder} /> },
{ id: "readonly", label: "Readonly", children: <Specimen readOnly defaultValue="Confirmed description" /> }, { id: "readonly", label: "Readonly", children: <Specimen readOnly defaultValue="Confirmed description" /> },
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable description" /> }, { id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable description" /> },
@@ -99,21 +117,15 @@ export const All: Story = {
/> />
</StorySection> </StorySection>
<StorySection title="Validation"> <StorySection title="Validation">
<StateBoard {/* Same 4-column track as States, so the two specimens keep the States width instead of stretching across the page. */}
<SpecimenGrid
columns={4}
states={[ states={[
{ id: "error", label: "Error", children: <Specimen error defaultValue="Invalid value" /> }, { id: "error", label: "Error", children: <Specimen error defaultValue="Invalid value" /> },
{ id: "error-message", label: "Error Message", children: <Specimen error message="설명을 입력하세요." /> }, { id: "error-message", label: "Error Message", children: <Specimen error message="설명을 입력하세요." /> },
]} ]}
/> />
</StorySection> </StorySection>
<StorySection title="Usage">
<StateBoard
states={[
{ id: "multiline", label: "Multiline · Wrap", children: <Specimen defaultValue={multiline} /> },
{ id: "resize", label: "Resize · none", children: <Specimen resize="none" defaultValue="This field cannot be resized." /> },
]}
/>
</StorySection>
</StoryPage> </StoryPage>
), ),
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
@@ -151,6 +163,17 @@ export const Required: Story = {
tags: ["verification"], tags: ["verification"],
render: () => narrow(<Specimen required />), render: () => narrow(<Specimen required />),
} }
// Former Usage section of the representative page, kept as separate Stories.
export const Multiline: Story = {
tags: ["verification"],
name: "Multiline · Wrap",
render: () => narrow(<Specimen defaultValue={multiline} />),
}
export const ResizeNone: Story = {
tags: ["verification"],
name: "Resize · none",
render: () => narrow(<Specimen resize="none" defaultValue="This field cannot be resized." />),
}
export const Readonly: Story = { export const Readonly: Story = {
tags: ["verification"], tags: ["verification"],
render: () => narrow(<Specimen readOnly defaultValue="Confirmed description" />), render: () => narrow(<Specimen readOnly defaultValue="Confirmed description" />),
+32 -12
View File
@@ -43,13 +43,17 @@ function Multiple({ showValue = true }: { showValue?: boolean }) {
) )
} }
function Disabled() { function DisabledGroup() {
return ( return (
<div className="grid gap-3">
<ToggleGroup type="single" variant="outline" defaultValue="a" disabled aria-label="비활성 그룹"> <ToggleGroup type="single" variant="outline" defaultValue="a" disabled aria-label="비활성 그룹">
<ToggleGroupItem value="a">A</ToggleGroupItem> <ToggleGroupItem value="a">A</ToggleGroupItem>
<ToggleGroupItem value="b">B</ToggleGroupItem> <ToggleGroupItem value="b">B</ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
)
}
function DisabledItem() {
return (
<ToggleGroup type="single" variant="outline" defaultValue="a" aria-label="비활성 항목"> <ToggleGroup type="single" variant="outline" defaultValue="a" aria-label="비활성 항목">
<ToggleGroupItem value="a">A</ToggleGroupItem> <ToggleGroupItem value="a">A</ToggleGroupItem>
<ToggleGroupItem value="b" disabled> <ToggleGroupItem value="b" disabled>
@@ -57,7 +61,6 @@ function Disabled() {
</ToggleGroupItem> </ToggleGroupItem>
<ToggleGroupItem value="c">C</ToggleGroupItem> <ToggleGroupItem value="c">C</ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
</div>
) )
} }
@@ -99,7 +102,8 @@ function Many() {
function Sizes() { function Sizes() {
return ( return (
<div className="grid gap-3"> // 3 equal columns on PC (iframe width 1000px and up): sm / default / lg; fewer columns below that (240px minimum).
<div className="grid items-start justify-items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{(["sm", "default", "lg"] as const).map((size) => ( {(["sm", "default", "lg"] as const).map((size) => (
<ToggleGroup key={size} type="multiple" size={size} variant="outline" aria-label={`${size} content kinds`}> <ToggleGroup key={size} type="multiple" size={size} variant="outline" aria-label={`${size} content kinds`}>
<ToggleGroupItem value="icon" aria-label={`${size} icon-only`}><BoldIcon /></ToggleGroupItem> <ToggleGroupItem value="icon" aria-label={`${size} icon-only`}><BoldIcon /></ToggleGroupItem>
@@ -112,33 +116,47 @@ function Sizes() {
} }
// No `component`: ToggleGroup props are a union (single | multiple), so Stories without args would not type-check. // No `component`: ToggleGroup props are a union (single | multiple), so Stories without args would not type-check.
const meta = { title: "Components/Form/Toggle Group", parameters: { layout: "padded" } } satisfies Meta // `internal` keeps it out of the sidebar by default; Story IDs and direct URLs stay valid.
const meta = { title: "Components/Form/Toggle Group", tags: ["internal"], parameters: { layout: "padded" } } satisfies Meta
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// The representative page shows the groups without example titles: the section title names them. // The representative page shows the groups without example titles: the section title names them. Equal columns on PC
const examples = (...nodes: ReactNode[]) => ( // (iframe width 1000px and up), fewer below that (200px minimum); the groups keep their own size (they are not stretched to the
<div className="flex flex-wrap items-start gap-x-10 gap-y-6"> // column). Full class names are listed so Tailwind can see them.
const specimenColumns = {
3: "min-[1000px]:grid-cols-3",
4: "min-[1000px]:grid-cols-4",
} as const
function Examples({ columns, nodes }: { columns: keyof typeof specimenColumns; nodes: ReactNode[] }) {
return (
<div className={`grid items-start justify-items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${specimenColumns[columns]}`}>
{nodes.map((node, i) => ( {nodes.map((node, i) => (
<div key={i}>{node}</div> <div key={i}>{node}</div>
))} ))}
</div> </div>
) )
}
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// Overview is merged into Variants (the default group is its first item). The 10-option Usage example is the hidden `ManyOptions` Story.
export const All: Story = { export const All: Story = {
name: "Toggle Group", name: "Toggle Group",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Toggle Group" /> <StoryPageHeader title="Toggle Group" />
<StorySection title="Overview">{examples(<Single showValue={false} />)}</StorySection> <StorySection title="Variants">
<Examples columns={4} nodes={[<Single key="single" showValue={false} />, <Multiple key="multiple" showValue={false} />, <Vertical key="vertical" />, <Spaced key="spaced" />]} />
</StorySection>
<StorySection title="Sizes"> <StorySection title="Sizes">
<Sizes /> <Sizes />
</StorySection> </StorySection>
<StorySection title="Variants">{examples(<Multiple showValue={false} />, <Vertical />, <Spaced />)}</StorySection> <StorySection title="States">
<StorySection title="States">{examples(<Disabled />)}</StorySection> {/* First two columns of a 3-column grid; the third stays empty. */}
<StorySection title="Usage">{examples(<div className="max-w-sm"><Many /></div>)}</StorySection> <Examples columns={3} nodes={[<DisabledGroup key="group" />, <DisabledItem key="item" />]} />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
@@ -146,6 +164,8 @@ export const All: Story = {
export const SingleSelection: Story = { tags: ["verification"], name: "Single", render: () => <Single /> } export const SingleSelection: Story = { tags: ["verification"], name: "Single", render: () => <Single /> }
export const MultipleSelection: Story = { tags: ["verification"], name: "Multiple", render: () => <Multiple /> } export const MultipleSelection: Story = { tags: ["verification"], name: "Multiple", render: () => <Multiple /> }
export const VerticalGroup: Story = { tags: ["verification"], name: "Vertical", render: () => <Vertical /> } export const VerticalGroup: Story = { tags: ["verification"], name: "Vertical", render: () => <Vertical /> }
// Former Usage section of the representative page (10 options that wrap), kept as its own Story.
export const ManyOptions: Story = { tags: ["verification"], name: "Many Options", render: () => <div className="max-w-sm"><Many /></div> }
export const ContentNodes: Story = { export const ContentNodes: Story = {
tags: ["verification"], tags: ["verification"],
+28 -8
View File
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { BoldIcon, ItalicIcon, StarIcon } from "lucide-react" import { BoldIcon, ItalicIcon, StarIcon } from "lucide-react"
import { Toggle, ToggleGroup, ToggleGroupItem } from "@hm-design/ui" import { Toggle, ToggleGroup, ToggleGroupItem } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Toggle is a persistent pressed-state control. The representative Story shows each state with real, clickable toggles; the same // Toggle is a persistent pressed-state control. The representative Story shows each state with real, clickable toggles; the same
@@ -39,7 +39,8 @@ function WithIcon() {
function Sizes() { function Sizes() {
return ( return (
<div className="grid gap-3"> // 3 equal columns on PC (iframe width 1000px and up): sm / default / lg; fewer columns below that (240px minimum).
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{(["sm", "default", "lg"] as const).map((size) => ( {(["sm", "default", "lg"] as const).map((size) => (
<div key={size} data-toggle-size={size} className="flex flex-wrap items-center gap-3"> <div key={size} data-toggle-size={size} className="flex flex-wrap items-center gap-3">
<Toggle size={size} variant="outline" aria-label={`${size} icon-only`}><StarIcon /></Toggle> <Toggle size={size} variant="outline" aria-label={`${size} icon-only`}><StarIcon /></Toggle>
@@ -71,21 +72,41 @@ const meta = { title: "Components/Form/Toggle", component: Toggle, parameters: {
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => ( // Representative-page layout (Storybook presentation only). StateBoard's own auto-fill grid cannot give equal columns that
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} /> // 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 (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div>
) )
}
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// Overview is renamed Variants. On PC (iframe width 1000px and up) Variants and States use the first two of the same 3 equal
// columns as the rows below, and the third column stays empty; below that they stack.
export const All: Story = { export const All: Story = {
name: "Toggle", name: "Toggle",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Toggle" /> <StoryPageHeader title="Toggle" />
<StorySection title="Overview">{board("offon")}</StorySection> <div className="grid gap-x-6 min-[1000px]:grid-cols-3">
<StorySection title="Variants">
<StateBoard min={10000} states={[specimen("offon")]} />
</StorySection>
<StorySection title="States">
<StateBoard min={10000} states={[specimen("disabled")]} />
</StorySection>
</div>
<StorySection title="단독 · 연결 그룹"> <StorySection title="단독 · 연결 그룹">
<StateBoard <SpecimenGrid
min={360}
states={[ states={[
{ id: "single-toggle", label: "단독", children: <Toggle variant="outline">알림</Toggle> }, { id: "single-toggle", label: "단독", children: <Toggle variant="outline">알림</Toggle> },
{ id: "group-single", label: "연결 그룹 · 단일 선택", children: <ToggleGroup type="single" variant="outline" defaultValue="left" aria-label="정렬"><ToggleGroupItem value="left">왼쪽</ToggleGroupItem><ToggleGroupItem value="center">가운데</ToggleGroupItem><ToggleGroupItem value="right">오른쪽</ToggleGroupItem></ToggleGroup> }, { id: "group-single", label: "연결 그룹 · 단일 선택", children: <ToggleGroup type="single" variant="outline" defaultValue="left" aria-label="정렬"><ToggleGroupItem value="left">왼쪽</ToggleGroupItem><ToggleGroupItem value="center">가운데</ToggleGroupItem><ToggleGroupItem value="right">오른쪽</ToggleGroupItem></ToggleGroup> },
@@ -96,7 +117,6 @@ export const All: Story = {
<StorySection title="Sizes"> <StorySection title="Sizes">
<Sizes /> <Sizes />
</StorySection> </StorySection>
<StorySection title="States">{board("disabled")}</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+35 -14
View File
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hm-design/ui" 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 { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Tooltip is a Radix overlay (Portal) with short, non-interactive text. The representative Story shows real triggers // Tooltip is a Radix overlay (Portal) with short, non-interactive text. The representative Story shows real triggers
@@ -20,11 +21,9 @@ function Basic({ defaultOpen }: { defaultOpen?: boolean }) {
) )
} }
function Placements() { function Placement({ side }: { side: "top" | "right" | "bottom" | "left" }) {
return ( return (
<div className="flex flex-wrap gap-3"> <Tooltip>
{(["top", "right", "bottom", "left"] as const).map((side) => (
<Tooltip key={side}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="primary" appearance="outline" size="sm"> <Button variant="primary" appearance="outline" size="sm">
{side} {side}
@@ -32,8 +31,6 @@ function Placements() {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side={side}>{`side="${side}"`}</TooltipContent> <TooltipContent side={side}>{`side="${side}"`}</TooltipContent>
</Tooltip> </Tooltip>
))}
</div>
) )
} }
@@ -70,6 +67,21 @@ const meta = { title: "Components/Overlay/Tooltip", component: Tooltip, paramete
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
// (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 (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
{states.map((state) => (
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
))}
</div>
)
}
// Representative Story: Overview is merged into Variants (Basic is its first item, then the four sides): 5 columns on PC.
export const All: Story = { export const All: Story = {
name: "Tooltip", name: "Tooltip",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
@@ -77,17 +89,26 @@ export const All: Story = {
<TooltipProvider> <TooltipProvider>
<StoryPage> <StoryPage>
<StoryPageHeader title="Tooltip" /> <StoryPageHeader title="Tooltip" />
<StorySection title="Overview">
<Basic />
</StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<Placements /> <SpecimenGrid
columns={5}
states={[
{ id: "basic", label: "Basic", children: <Basic /> },
{ id: "top", label: "Top", children: <Placement side="top" /> },
{ id: "right", label: "Right", children: <Placement side="right" /> },
{ id: "bottom", label: "Bottom", children: <Placement side="bottom" /> },
{ id: "left", label: "Left", children: <Placement side="left" /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<div className="flex flex-wrap gap-3"> <SpecimenGrid
<LongText /> columns={5}
<Disabled /> states={[
</div> { id: "long-text", label: "Long Text", children: <LongText /> },
{ id: "disabled", label: "Disabled", children: <Disabled /> },
]}
/>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
</TooltipProvider> </TooltipProvider>
+5 -4
View File
@@ -11,7 +11,7 @@ const palettes = [
] as const ] as const
type ColorStage = typeof stages[number] type ColorStage = typeof stages[number]
type PalettePrefix = typeof palettes[number]["prefix"] type PalettePrefix = typeof palettes[number]["prefix"]
const stageLabel = (stage: ColorStage) => stage === "default" ? "Default · 기본색" : stage.replaceAll("-", " ").replace(/^./, letter => letter.toUpperCase()) const stageLabel = (stage: ColorStage) => stage === "default" ? "Default · 기본색" : stage === "light-4" ? "Light 4 · 보더 하이라이트용" : stage.replaceAll("-", " ").replace(/^./, letter => letter.toUpperCase())
const paletteToken = (prefix: PalettePrefix, stage: ColorStage) => `color.palette.${prefix}-${stage}` as RuntimeThemeToken const paletteToken = (prefix: PalettePrefix, stage: ColorStage) => `color.palette.${prefix}-${stage}` as RuntimeThemeToken
const colorProperties = palettes.flatMap(palette => stages.map(stage => runtimeThemeContract[paletteToken(palette.prefix, stage)].property)) const colorProperties = palettes.flatMap(palette => stages.map(stage => runtimeThemeContract[paletteToken(palette.prefix, stage)].property))
@@ -35,12 +35,13 @@ function ColorChip({ prefix, stage, values }: { prefix: PalettePrefix; stage: Co
function ColorReferencePage() { function ColorReferencePage() {
const values = useResolvedProperties(colorProperties) const values = useResolvedProperties(colorProperties)
return <ReferencePage title="Color" intro="한맥 브랜드 컬러"> return <ReferencePage title="한맥 컬러">
{palettes.map(palette => <ReferenceSection key={palette.name} title={palette.name} note=""> {palettes.map(palette => <ReferenceSection key={palette.name} title={palette.name} note="">
<div className="hm-brand-color-row">{stages.map(stage => <ColorChip key={stage} prefix={palette.prefix} stage={stage} values={values} />)}</div> <div className="hm-brand-color-row">{stages.map(stage => <ColorChip key={stage} prefix={palette.prefix} stage={stage} values={values} />)}</div>
</ReferenceSection>)} </ReferenceSection>)}
</ReferencePage> </ReferencePage>
} }
const meta = { title: "Foundation/Color", parameters: { layout: "fullscreen" } } satisfies Meta // The sidebar name is "한맥 컬러"; `id` keeps the existing Story ID (foundation-color--all) stable.
const meta = { title: "Foundation/한맥 컬러", id: "foundation-color", parameters: { layout: "fullscreen" } } satisfies Meta
export default meta export default meta
export const All: StoryObj<typeof meta> = { name: "Color", render: () => <ColorReferencePage /> } export const All: StoryObj<typeof meta> = { name: "한맥 컬러", render: () => <ColorReferencePage /> }
+16 -5
View File
@@ -4,7 +4,15 @@ import { ReferencePage, ReferenceSection } from "./reference"
// Company colors come from the existing OrgChart presets; HEX values are never duplicated here. // Company colors come from the existing OrgChart presets; HEX values are never duplicated here.
// The chip markup and classes are shared with Foundation/Color (foundation.css). // The chip markup and classes are shared with Foundation/Color (foundation.css).
const companies = Object.values(companyColorPresets) // Display order only. PC rows: 한맥기술/삼안 · 장헌/피티씨 · 장헌산업/한라산업개발/바론컨설턴트.
// Row starts (jangheon, jangheon-industry) are pinned to column 1 in foundation.css via the section ids; companies not listed follow in preset order.
const companyRows = [["hanmac", "saman"], ["jangheon", "ptc"], ["jangheon-industry", "halla", "baron"]]
const presetCompanies = Object.values(companyColorPresets)
const listedIds = companyRows.flat()
const companies = [
...companyRows.flatMap(ids => ids.flatMap(id => presetCompanies.filter(company => company.companyId === id))),
...presetCompanies.filter(company => !listedIds.includes(company.companyId)),
]
function CompanyColorChip({ label, hex, main }: { label: string; hex: string; main: boolean }) { function CompanyColorChip({ label, hex, main }: { label: string; hex: string; main: boolean }) {
return <article className="hm-brand-color-chip" data-company-color={hex} data-base={main ? "" : undefined}> return <article className="hm-brand-color-chip" data-company-color={hex} data-base={main ? "" : undefined}>
@@ -14,15 +22,18 @@ function CompanyColorChip({ label, hex, main }: { label: string; hex: string; ma
} }
function CompanyColorsPage() { function CompanyColorsPage() {
return <ReferencePage title="Company Colors" intro="계열사 브랜드 컬러"> return <ReferencePage title="가족사 컬러">
{companies.map(company => <ReferenceSection key={company.companyId} title={company.companyName} note=""> <div className="hm-company-grid">
{companies.map(company => <ReferenceSection key={company.companyId} title={company.companyName} note="" id={`company-${company.companyId}`}>
<div className="hm-brand-color-row" data-company-id={company.companyId}> <div className="hm-brand-color-row" data-company-id={company.companyId}>
<CompanyColorChip label="메인" hex={company.primary} main /> <CompanyColorChip label="메인" hex={company.primary} main />
{company.secondary.map((hex, index) => <CompanyColorChip key={hex} label={`서브 ${index + 1}`} hex={hex} main={false} />)} {company.secondary.map((hex, index) => <CompanyColorChip key={hex} label={`서브 ${index + 1}`} hex={hex} main={false} />)}
</div> </div>
</ReferenceSection>)} </ReferenceSection>)}
</div>
</ReferencePage> </ReferencePage>
} }
const meta = { title: "Foundation/Company Colors", parameters: { layout: "fullscreen" } } satisfies Meta // The sidebar name is "가족사 컬러"; `id` keeps the existing Story ID (foundation-company-colors--all) stable.
const meta = { title: "Foundation/가족사 컬러", id: "foundation-company-colors", parameters: { layout: "fullscreen" } } satisfies Meta
export default meta export default meta
export const All: StoryObj<typeof meta> = { name: "Company Colors", render: () => <CompanyColorsPage /> } export const All: StoryObj<typeof meta> = { name: "가족사 컬러", render: () => <CompanyColorsPage /> }
+56 -4
View File
@@ -24,11 +24,12 @@
.hm-foundation-specimen small { display: block; font-size: var(--hm-button-font-size-sm); line-height: var(--hm-button-line-height-sm); font-weight: var(--hm-input-font-weight); } .hm-foundation-specimen small { display: block; font-size: var(--hm-button-font-size-sm); line-height: var(--hm-button-line-height-sm); font-weight: var(--hm-input-font-weight); }
.hm-foundation-spacing { max-width: 100%; height: 24px; margin-block: 12px; background: var(--hm-sem-state-active); } .hm-foundation-spacing { max-width: 100%; height: 24px; margin-block: 12px; background: var(--hm-sem-state-active); }
.hm-foundation-radius { width: 100%; height: 64px; margin-bottom: 16px; border: 1px solid var(--hm-sem-stroke-default); background: var(--hm-sem-bg-content); } .hm-foundation-radius { width: 100%; height: 64px; margin-bottom: 16px; border: 1px solid var(--hm-sem-stroke-default); background: var(--hm-sem-bg-content); }
.hm-foundation-layout-sample { display: grid; min-width: 0; background: var(--hm-sem-bg-content); border: 1px solid var(--hm-sem-stroke-input-default); }
.hm-foundation-layout-card { min-width: 0; border: 1px solid var(--hm-card-border-color); border-radius: var(--hm-card-radius); background: var(--hm-card-surface); }
.hm-brand-color-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; } .hm-brand-color-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
.hm-brand-color-chip { flex: 1 0 110px; min-width: 0; padding: 8px; border: 1px solid var(--hm-sem-stroke-default); } .hm-brand-color-chip { flex: 1 0 110px; min-width: 0; padding: 8px; border: 1px solid var(--hm-sem-stroke-default); }
.hm-brand-color-chip[data-base] { border: 2px solid var(--hm-sem-fg-primary); font-weight: var(--hm-font-weight-bold); } .hm-brand-color-chip[data-base] { border: 2px solid var(--hm-sem-fg-primary); font-weight: var(--hm-font-weight-bold); }
/* Hanmac Color only (data-color-stage): wider gap (8px + 16px extra = 24px) between Light 4 and Light 3 with a divider centered in it. */
.hm-brand-color-chip[data-color-stage="light-3"] { --hm-color-divider-extra: 16px; position: relative; margin-left: var(--hm-color-divider-extra); }
.hm-brand-color-chip[data-color-stage="light-3"]::before { content: ""; position: absolute; top: 0; bottom: 0; left: calc(-1px - 0.5px - (8px + var(--hm-color-divider-extra)) / 2); width: 1px; background: var(--hm-sem-stroke-default); pointer-events: none; }
.hm-brand-color-chip h3, .hm-brand-color-chip p { margin: 8px 0 0; font-size: 13px; } .hm-brand-color-chip h3, .hm-brand-color-chip p { margin: 8px 0 0; font-size: 13px; }
/* Color reference additions only; preserve the existing palette row and Default emphasis. */ /* Color reference additions only; preserve the existing palette row and Default emphasis. */
@@ -39,5 +40,56 @@
.hm-foundation .hm-color-gradient-stops .hm-brand-color-chip[data-gradient-stop-token] { flex-basis: 220px; } .hm-foundation .hm-color-gradient-stops .hm-brand-color-chip[data-gradient-stop-token] { flex-basis: 220px; }
.hm-foundation .hm-color-foreground-sample { padding: 16px; background: var(--hm-sem-bg-surface); } .hm-foundation .hm-color-foreground-sample { padding: 16px; background: var(--hm-sem-bg-surface); }
/* Company Colors: keep each chip close to the Color row width so 1-3 chips per company do not stretch across the page. */ /* Company Colors: two-level grid. Columns follow the Story content width (not the viewport): companies 3 -> 2 -> 1 (min 316px each),
.hm-foundation .hm-brand-color-row[data-company-id] .hm-brand-color-chip { flex: 0 1 140px; } chips up to 3 per row (min 100px each, 3 -> 2 -> 1). Chip size stays at one third even when a company has fewer chips. */
.hm-foundation .hm-company-grid { display: grid; gap: 48px 32px; grid-template-columns: repeat(auto-fill, minmax(max(316px, (100% - 64px) / 3), 1fr)); }
.hm-foundation .hm-company-grid > .sb-page-section { min-width: 0; margin-top: 0; }
/* Row starts for the 2 / 2 / 3 company layout (한맥·삼안 / 장헌·피티씨 / 장헌산업·한라산업개발·바론컨설턴트). */
.hm-foundation .hm-company-grid > #company-jangheon,
.hm-foundation .hm-company-grid > #company-jangheon-industry { grid-column-start: 1; }
.hm-foundation .hm-brand-color-row[data-company-id] { display: grid; gap: 8px; overflow: visible; padding-bottom: 0; grid-template-columns: repeat(auto-fill, minmax(max(100px, (100% - 16px) / 3), 1fr)); }
/* Typography: compact specification page. Font Family one line, Font Weight small cards, Specimens as comparison tables
(two columns on wide screens, stacked rows on narrow screens). */
.hm-foundation .hm-typo-family { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 16px; margin: 0; }
.hm-foundation .hm-typo-weights { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); gap: 12px; }
.hm-foundation .hm-typo-weight { padding: 12px 16px; }
.hm-foundation .hm-typo-weight p { margin: 0; }
.hm-foundation .hm-typo-weight-preview { margin: 4px 0 !important; font-size: var(--hm-card-title-font-size); line-height: var(--hm-card-title-line-height); }
.hm-foundation .hm-typo-tables { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px 32px; align-items: start; }
.hm-foundation .hm-typo-table { width: 100%; border-collapse: collapse; }
.hm-foundation .hm-typo-table th, .hm-foundation .hm-typo-table td { padding: 8px 12px 8px 0; border-bottom: 1px solid var(--hm-sem-stroke-input-default); text-align: left; vertical-align: baseline; font-weight: var(--hm-input-font-weight); }
.hm-foundation .hm-typo-table thead th { color: var(--hm-sem-fg-secondary); font-size: var(--hm-button-font-size-sm); line-height: var(--hm-button-line-height-sm); }
.hm-foundation .hm-typo-table tbody th { width: 27%; font-weight: var(--hm-font-weight-bold); }
.hm-foundation .hm-typo-table td:last-child { width: 26%; padding-right: 0; }
.hm-foundation .hm-typo-metrics span, .hm-foundation .hm-typo-metrics small { display: block; }
.hm-foundation .hm-typo-metrics small { font-size: var(--hm-button-font-size-sm); line-height: var(--hm-button-line-height-sm); }
@media (min-width: 1100px) { .hm-foundation .hm-typo-tables { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) {
.hm-foundation .hm-typo-table thead { display: none; }
.hm-foundation .hm-typo-table, .hm-foundation .hm-typo-table tbody, .hm-foundation .hm-typo-table tr, .hm-foundation .hm-typo-table th, .hm-foundation .hm-typo-table td { display: block; width: auto !important; }
.hm-foundation .hm-typo-table tr { padding: 8px 0; border-bottom: 1px solid var(--hm-sem-stroke-input-default); }
.hm-foundation .hm-typo-table th, .hm-foundation .hm-typo-table td { padding: 2px 0; border-bottom: 0; }
}
/* Layout: compact specification page (PC: three bands; narrow screens stack and scroll naturally).
Tightening applies only inside .hm-layout-ref; the common Story page and other Foundation pages are unchanged. No fixed heights or clipping. */
.hm-foundation .hm-layout-ref { display: grid; gap: 32px; word-break: keep-all; }
.hm-foundation .hm-layout-ref .sb-page-section { margin-top: 0; }
.hm-foundation .hm-layout-ref .sb-page-section-header { padding-bottom: 8px; margin-bottom: 12px; }
.hm-foundation .hm-layout-ref p, .hm-foundation .hm-layout-ref h3 { margin: 0; }
.hm-foundation .hm-layout-ref .hm-foundation-card, .hm-foundation .hm-layout-ref .hm-foundation-panel { padding: 8px 16px; }
.hm-foundation .hm-layout-ref code { font-size: var(--hm-button-font-size-sm); color: var(--hm-sem-fg-secondary); }
.hm-foundation .hm-layout-ref .hm-layout-value { font-weight: var(--hm-font-weight-bold); }
.hm-foundation .hm-layout-list { display: grid; gap: 8px 24px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 170px), 1fr)); align-content: center; }
.hm-foundation .hm-layout-spacing-grid { display: grid; gap: 12px; grid-template-columns: minmax(0, 1fr); }
.hm-foundation .hm-layout-spacing-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.hm-foundation .hm-layout-spacing-foot { display: flex; align-items: center; gap: 12px; margin-top: 4px; min-height: 14px; }
.hm-foundation .hm-layout-spacing-foot .hm-foundation-spacing { flex: none; height: 6px; margin: 0; }
.hm-foundation .hm-layout-spacing-foot .hm-foundation-note { min-width: 0; font-size: var(--hm-button-font-size-sm); }
.hm-foundation .hm-layout-radius-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr)); }
.hm-foundation .hm-layout-ref .hm-layout-radius .hm-foundation-radius { height: 24px; margin: 0 0 6px; }
.hm-foundation .hm-layout-radius .hm-layout-value { display: block; }
.hm-foundation .hm-layout-radius .hm-layout-spacing-head { margin-top: 0; }
@media (min-width: 480px) { .hm-foundation .hm-layout-spacing-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 640px) { .hm-foundation .hm-layout-spacing-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
+25 -6
View File
@@ -2,16 +2,35 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
import { layoutProperties, radiusReferences, spacingReferences } from "./reference-data" import { layoutProperties, radiusReferences, spacingReferences } from "./reference-data"
import { ReferencePage, ReferenceSection, useResolvedProperties } from "./reference" import { ReferencePage, ReferenceSection, useResolvedProperties } from "./reference"
// Compact specification page: item name, key value and preview only. PC shows every section within one screen, narrow screens stack and scroll naturally.
// Token names stay in the data (reference-data.ts) and in data-token attributes. Shortened wording (kept here for reference):
// Desktop: "Desktop Navigation · LNB 표시 · GNB desktop menu" / Mobile: "Mobile Navigation · LNB 숨김 · GNB mobile menu"
// Only verified items are shown (checked against the token definitions, CSS usage and docs on 2026-11-09):
// - "Mobile 20px" for Dashboard padding is not implemented: --hm-dashboard-padding is 34px in every viewport (docs/patterns/dashboard-layout.md).
// - --hm-radius-select is declared in default.css but has no consumer (Select uses --hm-select-radius -> --hm-radius-input), so it is not listed.
const radiusLabels: Record<string, string> = {
"--hm-radius-control-md": "Control",
"--hm-radius-button": "Button",
"--hm-radius-input": "Input",
"--hm-radius-container": "Container",
}
const shownRadiusReferences = radiusReferences.filter(property => property in radiusLabels)
function Item({ name, value, note }: { name: string; value: string; note?: string }) {
return <div><div className="hm-layout-spacing-head"><h3>{name}</h3><span className="hm-layout-value">{value}</span></div>{note ? <p className="hm-foundation-note">{note}</p> : null}</div>
}
function LayoutReferencePage() { function LayoutReferencePage() {
const values = useResolvedProperties(layoutProperties) const values = useResolvedProperties(layoutProperties)
return <ReferencePage title="Layout"> return <ReferencePage title="Layout">
<ReferenceSection title="Responsive Boundary"><div className="hm-foundation-grid"><article className="hm-foundation-panel"><h3>≥ 1024px · Desktop</h3><p>Desktop Navigation · LNB 표시 · GNB desktop menu</p></article><article className="hm-foundation-panel"><h3>≤ 1023px · Mobile</h3><p>Mobile Navigation · LNB 숨김 · GNB mobile menu</p></article></div></ReferenceSection> <div className="hm-layout-ref">
<ReferenceSection title="QA Reference Viewports"><div className="hm-foundation-grid"><div className="hm-foundation-panel"><h3>1280 × 900</h3><p>HM Desktop · QA reference</p></div><div className="hm-foundation-panel"><h3>390 × 844</h3><p>HM Mobile · QA reference</p></div></div></ReferenceSection> <ReferenceSection title="Responsive"><div className="hm-foundation-panel hm-layout-list"><Item name="Desktop" value="≥ 1024px" note="LNB 표시" /><Item name="Mobile" value="≤ 1023px" note="LNB 숨김" /></div></ReferenceSection>
<ReferenceSection title="Spacing · Current Usage Reference"><div className="hm-foundation-grid">{spacingReferences.map(([property, name, responsive]) =><article className="hm-foundation-card" key={property}><h3>{name}</h3><code>{property}</code><p data-layout-value>{values[property]}</p><div className="hm-foundation-spacing" style={{ width: `var(${property})` }} aria-hidden="true" />{responsive ? <p className="hm-foundation-note">{responsive}</p> : null}</article>)}</div></ReferenceSection> <ReferenceSection title="Spacing"><div className="hm-layout-spacing-grid">{spacingReferences.map(([property, name]) =><article className="hm-foundation-card" key={property} data-token={property}><div className="hm-layout-spacing-head"><h3>{name}</h3><span className="hm-layout-value" data-layout-value>{values[property]}</span></div><div className="hm-layout-spacing-foot"><div className="hm-foundation-spacing" style={{ width: `var(${property})` }} aria-hidden="true" /></div></article>)}</div></ReferenceSection>
<ReferenceSection title="Radius"><div className="hm-foundation-grid">{radiusReferences.map(property => <article className="hm-foundation-card" key={property}><div className="hm-foundation-radius" style={{ borderRadius: `var(${property})` }} /><code>{property}</code><p>{values[property]}</p></article>)}</div></ReferenceSection> <ReferenceSection title="Radius"><div className="hm-layout-radius-grid">{shownRadiusReferences.map(property => <article className="hm-foundation-card hm-layout-radius" key={property} data-token={property}><div className="hm-foundation-radius" style={{ borderRadius: `var(${property})` }} /><div className="hm-layout-spacing-head"><h3>{radiusLabels[property]}</h3><span className="hm-layout-value">{values[property]}</span></div></article>)}</div></ReferenceSection>
<ReferenceSection title="Layout / Container Reference"><div className="hm-foundation-layout-sample" style={{ padding: "var(--hm-dashboard-padding)", gap: "var(--hm-dashboard-gap)" }}><div className="hm-foundation-layout-card" style={{ padding: "var(--hm-card-padding)" }}><strong>Dashboard content</strong><p>가용 폭을 사용하며 긴 텍스트는 내부에서 줄바꿈합니다.</p></div><div className="hm-foundation-layout-card" style={{ padding: "var(--hm-card-padding)" }}>다음 섹션 · gap: {values["--hm-dashboard-gap"]}</div></div></ReferenceSection> </div>
</ReferencePage> </ReferencePage>
} }
const meta = { title: "Foundation/Layout", parameters: { layout: "fullscreen" } } satisfies Meta // Hidden from the sidebar with the verification tag; the Story ID and direct URL (foundation-layout--all) stay available.
const meta = { title: "Foundation/Layout", tags: ["verification"], parameters: { layout: "fullscreen" } } satisfies Meta
export default meta export default meta
export const All: StoryObj<typeof meta> = { name: "Layout", render: () => <LayoutReferencePage /> } export const All: StoryObj<typeof meta> = { name: "Layout", render: () => <LayoutReferencePage /> }
+52 -7
View File
@@ -1,20 +1,65 @@
import { useEffect, useRef, useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { typographyProperties, typographyReferences } from "./reference-data" import { typographyProperties, typographyReferences } from "./reference-data"
import { ReferencePage, ReferenceSection, Specimen, useResolvedProperties } from "./reference" import { ReferencePage, ReferenceSection, useResolvedProperties } from "./reference"
type TypographyReference = (typeof typographyReferences)[number]
const weights = [ const weights = [
{ property: "--hm-input-font-weight", label: "Regular" }, { property: "--hm-input-font-weight", label: "Regular" },
{ property: "--hm-font-weight-medium", label: "Medium" }, { property: "--hm-font-weight-medium", label: "Medium" },
{ property: "--hm-font-weight-bold", label: "Bold" }, { property: "--hm-font-weight-bold", label: "Bold" },
] ]
// Left table: text styles. Right table: form controls. Together they cover every entry in typographyReferences.
const textLabels = ["Page Header · title", "Card · title", "Page Header · description", "Label"]
const textRows = typographyReferences.filter(item => textLabels.includes(item.label))
const controlRows = typographyReferences.filter(item => !textLabels.includes(item.label))
function SpecimenRow({ label, size, line, weight }: TypographyReference & { weight?: string }) {
const ref = useRef<HTMLDivElement>(null)
const [metrics, setMetrics] = useState({ main: "", spacing: "" })
useEffect(() => {
let mounted = true
void document.fonts.ready.then(() => {
if (!mounted || !ref.current) return
const style = getComputedStyle(ref.current)
setMetrics({ main: `${style.fontSize} / ${style.lineHeight} / ${style.fontWeight}`, spacing: `letter-spacing: ${style.letterSpacing}` })
})
return () => { mounted = false }
}, [])
return <tr>
<th scope="row">{label}</th>
<td><div ref={ref} className="hm-typo-sample" data-typography-sample={label} lang="ko" style={{ fontSize: `var(${size})`, lineHeight: `var(${line})`, fontWeight: weight ? `var(${weight})` : "normal" }}>한맥 Hanmac 0123</div></td>
<td className="hm-typo-metrics" data-typography-metrics><span>{metrics.main}</span><small className="hm-foundation-note">{metrics.spacing}</small></td>
</tr>
}
function SpecimenTable({ rows }: { rows: readonly TypographyReference[] }) {
return <table className="hm-typo-table">
<thead><tr><th scope="col">Component</th><th scope="col">Sample</th><th scope="col">Size / Line / Weight</th></tr></thead>
<tbody>{rows.map(item => <SpecimenRow key={item.label} {...item} />)}</tbody>
</table>
}
function TypographyReferencePage() { function TypographyReferencePage() {
const values = useResolvedProperties(typographyProperties) const values = useResolvedProperties(typographyProperties)
return <ReferencePage title="Typography"> return <ReferencePage title="폰트">
<ReferenceSection title="Font Family"><div className="hm-foundation-panel"><p>Noto Sans KR → 맑은 고딕 → 시스템 sans-serif</p></div></ReferenceSection> <ReferenceSection title="Font Family">
<ReferenceSection title="Font Weight"><div className="hm-foundation-grid">{weights.map(({ property, label }) => <article className="hm-foundation-card" key={property}><code>{property}</code><p>{values[property]} — {label}</p><p style={{ fontWeight: `var(${property})` }}>한맥 프로젝트<br />Hanmac Project<br />0123456789</p></article>)}</div></ReferenceSection> <p className="hm-typo-family"><span>Noto Sans KR → 맑은 고딕 → 시스템 sans-serif</span><code className="hm-foundation-note">--hm-font-family-base: {values["--hm-font-family-base"]}</code></p>
<ReferenceSection title="Component Specimens"><div className="hm-foundation-specimens">{typographyReferences.map(item => <article className="hm-foundation-panel" key={item.label}><h3>{item.label}</h3><Specimen {...item} /></article>)}</div></ReferenceSection> </ReferenceSection>
<ReferenceSection title="Font Weight">
<div className="hm-typo-weights">{weights.map(({ property, label }) => <article className="hm-foundation-card hm-typo-weight" key={property}>
<p className="hm-typo-weight-meta"><strong>{label}</strong> <span className="hm-foundation-note">{values[property]}</span></p>
<p className="hm-typo-weight-preview" style={{ fontWeight: `var(${property})` }}>한맥 Hanmac 0123</p>
<code className="hm-foundation-note">{property}</code>
</article>)}</div>
</ReferenceSection>
<ReferenceSection title="Typography Specimens">
<div className="hm-typo-tables"><SpecimenTable rows={textRows} /><SpecimenTable rows={controlRows} /></div>
</ReferenceSection>
</ReferencePage> </ReferencePage>
} }
const meta = { title: "Foundation/Typography", parameters: { layout: "fullscreen" } } satisfies Meta // The sidebar name is "폰트"; `id` keeps the existing Story ID (foundation-typography--all) stable.
const meta = { title: "Foundation/폰트", id: "foundation-typography", parameters: { layout: "fullscreen" } } satisfies Meta
export default meta export default meta
export const All: StoryObj<typeof meta> = { name: "Typography", render: () => <TypographyReferencePage /> } export const All: StoryObj<typeof meta> = { name: "폰트", render: () => <TypographyReferencePage /> }
+7 -1
View File
@@ -1,7 +1,7 @@
/* Department Colors Storybook adapter only. The actual Tunnel sample/stage /* Department Colors Storybook adapter only. The actual Tunnel sample/stage
frame comes from center-stories.css; this sheet never selects employee cards. */ frame comes from center-stories.css; this sheet never selects employee cards. */
.hm-orgchart-department-preview .hm-orgchart-department-colors-design { .hm-orgchart-department-preview .hm-orgchart-department-colors-design {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sb-center-column-gap); gap: var(--sb-center-column-gap);
} }
@@ -43,6 +43,12 @@
background: transparent; background: transparent;
} }
@media (max-width: 960px) {
.hm-orgchart-department-preview .hm-orgchart-department-colors-design {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) { @media (max-width: 760px) {
.hm-orgchart-department-preview .hm-orgchart-department-colors-design { .hm-orgchart-department-preview .hm-orgchart-department-colors-design {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { CoreComponentPreview } from './core-component-preview'; import { CoreComponentPreview } from './core-component-preview';
import { organizationStylePresets } from '../../packages/org-chart/src/core/style-presets'; import { organizationStylePresets } from '../../packages/org-chart/src/core/style-presets';
const meta = { title: 'OrgChart/Components/Cards', tags: ['internal'], component: CoreComponentPreview, parameters: { layout: 'padded', controls: { disable: true } } } satisfies Meta<typeof CoreComponentPreview>; const meta = { title: '조직도/Components/Cards', id: 'orgchart-components-cards', tags: ['internal'], component: CoreComponentPreview, parameters: { layout: 'padded', controls: { disable: true } } } satisfies Meta<typeof CoreComponentPreview>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
function organization(variant: number): Story { function organization(variant: number): Story {
@@ -2,7 +2,7 @@ import { StrictMode } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { DepartmentTypographyPreview } from './org-chart-page-previews'; import { DepartmentTypographyPreview } from './org-chart-page-previews';
const meta = { title: 'OrgChart/Department Colors', component: DepartmentTypographyPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof DepartmentTypographyPreview>; const meta = { title: '조직도/부서별 색상', component: DepartmentTypographyPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof DepartmentTypographyPreview>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const DepartmentColors: Story = { name: 'Department Colors', parameters: { __id: 'orgchart--department-colors' } }; export const DepartmentColors: Story = { name: '부서별 색상', parameters: { __id: 'orgchart--department-colors' } };
+3 -3
View File
@@ -50,7 +50,7 @@ function ExpandedPreview() {
})), []); })), []);
return <StoryPage className="hm-orgchart-story-page hm-orgchart-story-expanded"> return <StoryPage className="hm-orgchart-story-page hm-orgchart-story-expanded">
<StoryPageHeader title="Expanded OrgChart" /> <StoryPageHeader title="확장 조직도" />
{/* Preview first: the chart sits right under the page title; its own header already names it, so no section header. */} {/* Preview first: the chart sits right under the page title; its own header already names it, so no section header. */}
<div className="hm-orgchart-story hm-orgchart-story-lead"> <div className="hm-orgchart-story hm-orgchart-story-lead">
{error ? <p role="alert" className="hm-orgchart-story-note">{error}</p> : null} {error ? <p role="alert" className="hm-orgchart-story-note">{error}</p> : null}
@@ -61,7 +61,7 @@ function ExpandedPreview() {
</StoryPage>; </StoryPage>;
} }
const meta = { title: 'OrgChart/Expanded OrgChart', component: ExpandedPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof ExpandedPreview>; const meta = { title: '조직도/확장 조직도', component: ExpandedPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof ExpandedPreview>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const ExpandedOrgChart: Story = { name: 'Expanded OrgChart', parameters: { __id: 'orgchart--expanded-org-chart' } }; export const ExpandedOrgChart: Story = { name: '확장 조직도', parameters: { __id: 'orgchart--expanded-org-chart' } };
@@ -2,7 +2,7 @@ import { StrictMode } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { CompositionPreview } from './org-chart-page-previews'; import { CompositionPreview } from './org-chart-page-previews';
const meta = { title: 'OrgChart/Full Composition', component: CompositionPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CompositionPreview>; const meta = { title: '조직도/전체 구성', component: CompositionPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CompositionPreview>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const FullComposition: Story = { name: 'Full Composition', parameters: { __id: 'orgchart--full-composition' } }; export const FullComposition: Story = { name: '전체 구성', parameters: { __id: 'orgchart--full-composition' } };
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { CoreComponentPreview } from './core-component-preview'; import { CoreComponentPreview } from './core-component-preview';
import './org-chart-stories.css'; import './org-chart-stories.css';
const meta = { title: 'OrgChart/Verification/Overview', component: CoreComponentPreview, tags: ['internal'], parameters: { layout: 'padded', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CoreComponentPreview>; const meta = { title: '조직도/Verification/Overview', component: CoreComponentPreview, tags: ['internal'], parameters: { layout: 'padded', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CoreComponentPreview>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
// Literal IDs preserve every existing direct URL after moving the hidden CSF entries. // Literal IDs preserve every existing direct URL after moving the hidden CSF entries.
+2 -2
View File
@@ -2,7 +2,7 @@ import { StrictMode } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { OverviewPreview } from './org-chart-page-previews'; import { OverviewPreview } from './org-chart-page-previews';
const meta = { title: 'OrgChart/Overview', component: OverviewPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof OverviewPreview>; const meta = { title: '조직도/개요', component: OverviewPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof OverviewPreview>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const LogoPersonnelSummary: Story = { name: 'Overview', parameters: { __id: 'orgchart--logo-personnel-summary' } }; export const LogoPersonnelSummary: Story = { name: '개요', parameters: { __id: 'orgchart--logo-personnel-summary' } };
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more