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
This commit is contained in:
shkim4 committed 2026-11-09 12:01:03 +09:00
1 parent 228944a06b
commit fc0a3e7725
12 files changed
+246 -86

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
/* Storybook presentation only: Patterns/Layout/LNB Stories. Scoped by the decorator wrapper in lnb.stories.tsx, so the shared
@hm-design/ui LNB Pattern, its tokens and every other Story keep their values.
- min-width 212px: a floor, not a fixed width. The column follows --hm-lnb-width (240px from 1024px, see below) and may be wider.
- menu font 14px: --hm-lnb-item-font-size is redefined for this subtree; weight (700) and line height (28px) stay.
- row height: depth1 48px, depth2 and deeper 42px. Heights come from min-height + vertical padding (not a fixed height), so a
label that wraps grows the row instead of being clipped. Current and hover only change color/background, not the height. */
.hm-lnb-story {
display: contents;
--hm-lnb-item-font-size: 14px;
/* Existing variable: min-height of every item and group trigger (used by nothing else). */
--hm-lnb-item-min-height: 48px;
/* Existing variable: bottom padding of [data-slot="lnb-brand"] (22px by default; top 0 and the 30px sides are unchanged). */
--hm-lnb-brand-padding-bottom: 8px;
}
.hm-lnb-story [data-slot="lnb"] {
min-width: 212px;
}
/* PC column width 240px through the existing --hm-lnb-width (the column, the Story grid track and the menu row offset all read it).
Only from 1024px: the package sets --hm-lnb-width to 0px below that and collapses the column, which must keep working. */
@media (min-width: 1024px) {
.hm-lnb-story {
--hm-lnb-width: 240px;
}
}
/* Profile text (PC column): name (strong) 14px, description (p) 12px with no top margin, both 700. The package's existing 1px text
stroke on strong/p stays as is; the color and the 1.5 line-height ratio are unchanged (21px + 18px lines in the 48px profile).
The sizes use the existing profile variables; the container gets the name size. */
.hm-lnb-story {
--hm-lnb-profile-name-size: 14px;
--hm-lnb-profile-description-size: 12px;
}
.hm-lnb-story [data-slot="lnb"] [data-slot="lnb-profile-text"] {
font-size: 14px;
}
.hm-lnb-story [data-slot="lnb"] [data-slot="lnb-profile-text"] :is(strong, p) {
font-weight: 700;
}
.hm-lnb-story [data-slot="lnb"] [data-slot="lnb-profile-text"] p {
margin-top: 0;
}
/* Divider lines, same design as the Storybook Manager section divider (manager.css, .sidebar-subheading ::before): a 2px double
line (black 30% over white 40%) faded at both ends by a mask. Shared by the profile divider and the group divider below. */
.hm-lnb-story [data-slot="lnb"] > [data-slot="lnb-profile"]::before,
.hm-lnb-story .hm-lnb-story-group-divider {
height: 2px;
background: linear-gradient(var(--hm-lnb-text-stroke-color) 0 50%, color-mix(in srgb, var(--hm-sem-palette-white) 40%, transparent) 50% 100%);
mask-image: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent);
pointer-events: none;
}
/* Above the PC profile, 12px above the block. Like the Manager, it starts after the left inset (--hm-lnb-inset-start, 20px: where
the menu rows start) and stops 20px short of the right edge. The profile button is only as wide as its content, so the line is
sized from the column width (--hm-lnb-width) and offset back by the button's left margin. It sits in the 22px gap between the
menu and the profile, never over the avatar or text. The mobile Sheet profile is not under [data-slot="lnb"]. */
.hm-lnb-story [data-slot="lnb"] > [data-slot="lnb-profile"] {
position: relative;
}
.hm-lnb-story [data-slot="lnb"] > [data-slot="lnb-profile"]::before {
content: "";
position: absolute;
top: -12px;
left: calc(var(--hm-lnb-inset-start) + 8px - var(--hm-lnb-profile-padding-x));
width: calc(var(--hm-lnb-width) - var(--hm-lnb-inset-start) - var(--hm-lnb-inset-end));
}
/* Between large menu groups (a role="separator" element, direct child of LNBNav). The package already puts a direct child at the
menu rows' x start (margin-left), so only the other margins are set: the line ends 20px short of the column's right edge (the
nav is 1px wider than the column), and 12px + 2px + 10px = 24px in total, the Manager's section gap. */
.hm-lnb-story .hm-lnb-story-group-divider {
flex: none; /* keep the 2px in a height-limited (scrolling) menu, where an empty item would otherwise shrink to 0 */
margin-top: 12px;
margin-right: calc(var(--hm-lnb-inset-end) + 1px);
margin-bottom: 10px;
}
/* Profile in the mobile Sheet (<=1023px): 14px, 700, no text stroke. The Sheet is portaled to body, outside .hm-lnb-story, so the
Story marks its LNBProfile with a class instead. The doubled attribute raises specificity above the package's
[data-slot="lnb-profile"] strong/p rules. Menu links and the mobile header already have no text stroke. */
.hm-lnb-story-mobile-profile[data-slot="lnb-profile"] [data-slot="lnb-profile-text"],
.hm-lnb-story-mobile-profile[data-slot="lnb-profile"] strong {
font-size: 14px;
}
/* Description: 12px with no top margin, like the PC profile (the line height is unchanged, so the Sheet profile is 4px lower
without the old 4px margin: 8 + 20 + 20 + 8 = 56px). */
.hm-lnb-story-mobile-profile[data-slot="lnb-profile"] p {
font-size: 12px;
margin-top: 0;
}
.hm-lnb-story-mobile-profile[data-slot="lnb-profile"] :is(strong, p) {
font-weight: 700;
-webkit-text-stroke: 0;
paint-order: normal;
}
/* depth1: nav items and top-level group triggers. The package hard-codes 14px vertical padding (28px line + 28px = 56px). */
.hm-lnb-story [data-slot="lnb-item"],
.hm-lnb-story [data-slot="lnb-group-trigger"] {
padding-top: 10px;
padding-bottom: 10px;
}
/* depth2: links directly inside a group and nested group triggers (28px line + 14px = 42px). */
.hm-lnb-story [data-slot="lnb-group-content"] > [data-slot="lnb-item"],
.hm-lnb-story [data-slot="lnb-group-content"] [data-slot="lnb-group-trigger"] {
min-height: 42px;
padding-top: 7px;
padding-bottom: 7px;
}
/* depth3: links in a nested group (24px line + 18px = 42px). --hm-lnb-third-min-height is also the Profile's height, so it is
overridden on the row's link instead of through the variable. */
.hm-lnb-story [data-slot="lnb-group-content"] [data-slot="lnb-third-row"] > [data-slot="lnb-item"] {
min-height: 42px;
padding-top: 9px;
padding-bottom: 9px;
}
+86 -58
View File
@@ -32,9 +32,11 @@ import {
} from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import "./lnb.stories.css"
// LNB is a composition pattern. Layout visual reference is the ui-viz LNB (272px green column, 18/700 items, Beige current item joined
// to the content, Black 10% hover, Black 8% open group). The menu data and the current location are local sample data for this Story
// LNB is a composition pattern. Layout visual reference is the ui-viz LNB (the shared package default: 272px green column, 18/700 items,
// Beige current item joined to the content, Black 10% hover, Black 8% open group). This Story overrides only the display values (240px column,
// 212px min width, 14px items, 48/42px rows) in lnb.stories.css; the package defaults are unchanged. The menu data and the current location are local sample data for this Story
// (no router). The content next to the column is a positioned surface with `--hm-lnb-join` rounded left corners: the LNB Mode page
// owns it, here it only shows the connection. The column is desktop only (hidden below 1024px).
@@ -106,6 +108,12 @@ const contentStyle: CSSProperties = {
type Child = { id: string; label: string }
type LocalNode = { id: string; label: string; icon?: LucideIcon; children?: LocalNode[] }
// Line between large menu groups (a top-level group and its neighbours). A direct child of LNBNav, so it follows the menu rows'
// x start; the look is in lnb.stories.css (same line as the profile divider). Desktop column only: the mobile Sheet is unchanged.
function GroupDivider() {
return <div role="separator" className="hm-lnb-story-group-divider" />
}
function Menu({
initial = "overview",
groupOpen = true,
@@ -116,6 +124,7 @@ function Menu({
many,
height,
profile = true,
dividers = true,
}: {
initial?: string | null
groupOpen?: boolean
@@ -128,6 +137,8 @@ function Menu({
height?: number
/** Story composition only: GNB + LNB gives profile ownership to the GNB. */
profile?: boolean
/** Group divider lines around the menu group. `false` hides them (the profile divider above the profile stays). */
dividers?: boolean
}) {
const [current, setCurrent] = useState<string | null>(initial)
const [open, setOpen] = useState(groupOpen)
@@ -162,6 +173,7 @@ function Menu({
<LayoutGridIcon aria-hidden="true" />
{longLabels ? "프로젝트 현황과 전체 일정을 한눈에 보는 개요" : "메뉴 1"}
</LNBItem>
{dividers ? <GroupDivider /> : null}
<LNBGroup label={longLabels ? "업무 관리와 진행 상황 점검" : "메뉴 2"} current={Boolean(current?.startsWith("tasks/"))} open={open} onOpenChange={setOpen}>
{children.map((child) => (
<LNBItem key={child.id} href={`#${child.id}`} active={current === child.id} onClick={go(child.id)}>
@@ -169,6 +181,7 @@ function Menu({
</LNBItem>
))}
</LNBGroup>
{dividers ? <GroupDivider /> : null}
<LNBItem href="#reports" active={current === "reports"} onClick={go("reports")}>
<ChartColumnIcon aria-hidden="true" />
메뉴 3
@@ -262,6 +275,7 @@ function ThirdLevelMenu({
<LayoutGridIcon aria-hidden="true" />
대시보드
</LNBItem>
<GroupDivider />
<LNBGroup label="프로젝트 관리" current={onPath("projects")} open={open.projects} onOpenChange={(value) => setOpen((state) => ({ ...state, projects: value }))}>
<LNBGroup label="디자인 관리" current={onPath("projects/design")} open={open.design} onOpenChange={(value) => setOpen((state) => ({ ...state, design: value }))}>
{thirdLevelChildren(longLabels).map((child) => (
@@ -277,6 +291,7 @@ function ThirdLevelMenu({
일정 관리
</LNBItem>
</LNBGroup>
<GroupDivider />
<LNBItem href="#reports" active={current === "reports"} onClick={go("reports")}>
<ChartColumnIcon aria-hidden="true" />
현황 보고
@@ -361,6 +376,7 @@ function ResponsiveSpecimen({
brand = "프로젝트 대시보드",
longLabels,
height,
dividers = true,
}: {
nodes: LocalNode[]
initial: string | null
@@ -370,6 +386,8 @@ function ResponsiveSpecimen({
brand?: string
longLabels?: boolean
height?: number
/** Group divider lines in the desktop menu, like `Menu`'s `dividers`. `false` hides them (the profile divider stays). */
dividers?: boolean
}) {
const [current, setCurrent] = useState<string | null>(initial)
const [open, setOpen] = useState<Record<string, boolean>>(initialOpen)
@@ -380,25 +398,27 @@ function ResponsiveSpecimen({
}
const onPath = (id: string) => Boolean(current?.startsWith(`${id}/`))
const isOpen = (id: string) => open[id] ?? true
const lnbMenu = (items: LocalNode[]): ReactNode =>
items.map((node) =>
node.children ? (
<LNBGroup
key={node.id}
label={node.label}
current={onPath(node.id)}
open={isOpen(node.id)}
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
>
{lnbMenu(node.children)}
</LNBGroup>
) : (
<LNBItem key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
{node.icon ? <node.icon aria-hidden="true" /> : null}
{node.label}
</LNBItem>
)
)
const lnbMenu = (items: LocalNode[], top = true): ReactNode =>
items.map((node, index) => (
<Fragment key={node.id}>
{dividers && top && index > 0 && (node.children || items[index - 1].children) ? <GroupDivider /> : null}
{node.children ? (
<LNBGroup
label={node.label}
current={onPath(node.id)}
open={isOpen(node.id)}
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
>
{lnbMenu(node.children, false)}
</LNBGroup>
) : (
<LNBItem href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
{node.icon ? <node.icon aria-hidden="true" /> : null}
{node.label}
</LNBItem>
)}
</Fragment>
))
const sheetMenu = (items: LocalNode[]): ReactNode =>
items.map((node) =>
node.children ? (
@@ -449,6 +469,7 @@ function ResponsiveSpecimen({
modal={false}
trigger={
<LNBProfile
className="hm-lnb-story-mobile-profile"
name={profile.name}
description={profile.description}
>
@@ -500,7 +521,7 @@ const standaloneNav: LocalNode[] = [
{ id: "settings", label: "설정", icon: SettingsIcon },
]
function Standalone({ mobileOpen = false, profileOpen = false }: { mobileOpen?: boolean; profileOpen?: boolean }) {
function Standalone({ mobileOpen = false, profileOpen = false, dividers = true }: { mobileOpen?: boolean; profileOpen?: boolean; dividers?: boolean }) {
const [current, setCurrent] = useState("projects/design/tokens")
const [open, setOpen] = useState<Record<string, boolean>>({})
const go = (id: string) => (event: MouseEvent) => {
@@ -508,25 +529,27 @@ function Standalone({ mobileOpen = false, profileOpen = false }: { mobileOpen?:
setCurrent(id)
}
const onPath = (id: string) => current.startsWith(`${id}/`)
const lnbMenu = (nodes: LocalNode[]): ReactNode =>
nodes.map((node) =>
node.children ? (
<LNBGroup
key={node.id}
label={node.label}
current={onPath(node.id)}
open={open[node.id] ?? true}
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
>
{lnbMenu(node.children)}
</LNBGroup>
) : (
<LNBItem key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
{node.icon ? <node.icon aria-hidden="true" /> : null}
{node.label}
</LNBItem>
)
)
const lnbMenu = (nodes: LocalNode[], top = true): ReactNode =>
nodes.map((node, index) => (
<Fragment key={node.id}>
{dividers && top && index > 0 && (node.children || nodes[index - 1].children) ? <GroupDivider /> : null}
{node.children ? (
<LNBGroup
label={node.label}
current={onPath(node.id)}
open={open[node.id] ?? true}
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
>
{lnbMenu(node.children, false)}
</LNBGroup>
) : (
<LNBItem href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
{node.icon ? <node.icon aria-hidden="true" /> : null}
{node.label}
</LNBItem>
)}
</Fragment>
))
const sheetMenu = (nodes: LocalNode[]): ReactNode =>
nodes.map((node) =>
node.children ? (
@@ -567,6 +590,7 @@ function Standalone({ mobileOpen = false, profileOpen = false }: { mobileOpen?:
modal={false}
trigger={
<LNBProfile
className="hm-lnb-story-mobile-profile"
name={profileData.name}
description={profileData.description}
>
@@ -598,7 +622,7 @@ const mobileViewport = {
// Representative-board preview of a Story at a fixed viewport. The LNB/bar switch is a viewport media query, so the board embeds the
// verification Story (the same `Standalone` composition) in an iframe of that width instead of rebuilding it in a narrow container.
// Each iframe is its own document, so the closed Sheets do not share a portal or focus with the board.
function ViewportPreview({ story, label, width, height }: { story: string; label: string; width: number; height: number }) {
function ViewportPreview({ story, label, width, height, args }: { story: string; label: string; width: number; height: number; args?: string }) {
return (
<figure className="grid min-w-0 content-start gap-2">
<figcaption className="text-sm text-muted-foreground">
@@ -607,7 +631,7 @@ function ViewportPreview({ story, label, width, height }: { story: string; label
<div className="max-w-full overflow-x-auto">
<iframe
title={`${label} ${width}px`}
src={`iframe.html?id=patterns-layout-lnb--${story}&viewMode=story`}
src={`iframe.html?id=patterns-layout-lnb--${story}&viewMode=story${args ? `&args=${args}` : ""}`}
width={width}
height={height}
className="box-content block border border-black/10"
@@ -617,7 +641,8 @@ function ViewportPreview({ story, label, width, height }: { story: string; label
)
}
const meta = { title: "Patterns/Layout/LNB", component: LNB, parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta<typeof LNB>
// `hm-lnb-story` (display: contents, see lnb.stories.css) scopes the 212px min width and 14px menu font to this Story only.
const meta = { title: "Patterns/Layout/LNB", component: LNB, decorators: [(Story) => <div className="hm-lnb-story"><Story /></div>], parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta<typeof LNB>
export default meta
type Story = StoryObj<typeof meta>
@@ -632,8 +657,8 @@ export const All: Story = {
<StateBoard
min={560}
states={[
{ id: "selected", label: "Selected", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} /> },
{ id: "third-selected", label: "3뎁스 · Selected", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": true }} /> },
{ id: "selected", label: "Selected", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} dividers={false} /> },
{ id: "third-selected", label: "3뎁스 · Selected", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": true }} dividers={false} /> },
]}
/>
</StorySection>
@@ -641,12 +666,8 @@ export const All: Story = {
<StateBoard
min={560}
states={[
{ id: "default", label: "Default", children: <ResponsiveSpecimen nodes={simpleNav()} initial={null} initialOpen={{ tasks: false }} /> },
{ id: "expanded", label: "Expanded", children: <ResponsiveSpecimen nodes={simpleNav()} initial={null} initialOpen={{ tasks: true }} /> },
{ id: "selected-child", label: "Selected Child", children: <ResponsiveSpecimen nodes={simpleNav()} initial="tasks/list" initialOpen={{ tasks: true }} /> },
{ id: "collapsed-with-selection", label: "Collapsed With Selection", children: <ResponsiveSpecimen nodes={simpleNav()} initial="tasks/list" initialOpen={{ tasks: false }} /> },
{ id: "third-second-collapsed", label: "3뎁스 · 2뎁스 접힘", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": false }} /> },
{ id: "third-first-collapsed", label: "3뎁스 · 1뎁스 접힘", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: false, "projects/design": true }} /> },
{ id: "states-third-selected", label: "3뎁스 · Selected", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": true }} /> },
]}
/>
</StorySection>
@@ -654,8 +675,8 @@ export const All: Story = {
<StateBoard
min={560}
states={[
{ id: "with-logo", label: "With Logo", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} logo={testLogo} /> },
{ id: "long", label: "Long Labels", children: <ResponsiveSpecimen nodes={simpleNav(true)} initial="tasks/list" initialOpen={{ tasks: true }} longLabels /> },
{ id: "with-logo", label: "With Logo", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} logo={testLogo} dividers={false} /> },
{ id: "long", label: "Long Labels", children: <ResponsiveSpecimen nodes={simpleNav(true)} initial="tasks/list" initialOpen={{ tasks: true }} longLabels dividers={false} /> },
]}
/>
</StorySection>
@@ -663,14 +684,14 @@ export const All: Story = {
<StateBoard
min={560}
states={[
{ id: "scroll", label: "Scroll", children: <ResponsiveSpecimen nodes={simpleNav(false, true)} initial="overview" initialOpen={{ tasks: true }} height={460} /> },
{ id: "third-scroll", label: "3뎁스 · Scroll", children: <ResponsiveSpecimen nodes={thirdLevelNav(true, true)} initial="projects/design/layout" initialOpen={{ projects: true, "projects/design": true }} longLabels height={460} /> },
{ id: "scroll", label: "Scroll", children: <ResponsiveSpecimen nodes={simpleNav(false, true)} initial="overview" initialOpen={{ tasks: true }} height={460} dividers={false} /> },
{ id: "third-scroll", label: "3뎁스 · Scroll", children: <ResponsiveSpecimen nodes={thirdLevelNav(true, true)} initial="projects/design/layout" initialOpen={{ projects: true, "projects/design": true }} longLabels height={460} dividers={false} /> },
]}
/>
</StorySection>
<StorySection title="Usage · Standalone 반응형">
<div className="flex flex-wrap items-start gap-6">
<ViewportPreview story="standalone-desktop" label="Desktop" width={1024} height={640} />
<ViewportPreview story="standalone-desktop" label="Desktop" width={1024} height={640} args="dividers:!false" />
<ViewportPreview story="standalone-mobile" label="Mobile" width={390} height={640} />
</div>
</StorySection>
@@ -679,7 +700,7 @@ export const All: Story = {
}
export const Default: Story = { tags: ["verification"], render: () => <Menu initial={null} groupOpen={false} /> }
export const Selected: Story = { tags: ["verification"], render: () => <Menu /> }
export const Selected: Story = { tags: ["verification"], render: () => <Menu dividers={false} /> }
export const Expanded: Story = { tags: ["verification"], render: () => <Menu initial={null} /> }
export const SelectedChild: Story = { tags: ["verification"], name: "Selected Child", render: () => <Menu initial="tasks/list" /> }
export const CollapsedWithSelection: Story = { tags: ["verification"], name: "Collapsed With Selection", render: () => <Menu initial="tasks/list" groupOpen={false} /> }
@@ -733,7 +754,14 @@ export const WithGNB: Story = {
// LNB without a GNB, responsive. Desktop (>= 1024px): the column. Mobile (<= 1023px): the column is hidden and the connected-content
// column becomes a full-width stack whose first row opens a Sheet with the same tree, selected item and every depth.
export const StandaloneDesktop: Story = { tags: ["verification"], name: "LNB Standalone Desktop", render: () => <Standalone /> }
// `dividers` is a Story arg (default true: the Story itself keeps its group dividers). The representative board's Desktop preview
// opens this Story with `&args=dividers:!false` (see ViewportPreview) to show it without them.
export const StandaloneDesktop: StoryObj<{ dividers: boolean }> = {
tags: ["verification"],
name: "LNB Standalone Desktop",
args: { dividers: true },
render: (args) => <Standalone dividers={args.dividers} />,
}
export const LNBProfileMenuOpen: Story = { tags: ["verification"], name: "LNB Profile Menu Open", render: () => <Standalone profileOpen /> }
export const StandaloneMobile: Story = { tags: ["verification"], name: "LNB Standalone Mobile", ...mobileViewport, render: () => <Standalone /> }
export const StandaloneMobileMenuOpen: Story = {