Initial commit: HMDesignSystem baseline

This commit is contained in:
shkim4 committed 2026-10-08 16:58:09 +09:00
commit b6a0a61103
486 files changed
+68432

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { allColorProperties, componentColors, primitiveColors, semanticColors, type ColorReference } from "./reference-data"
import { ReferencePage, ReferenceSection, useResolvedProperties } from "./reference"
function ColorCards({ items, values }: { items: ColorReference[]; values: Record<string, string> }) {
return <div className="hm-foundation-grid">{items.map(item => <article className="hm-foundation-card" key={item.property} data-color-property={item.property}>
<div className="hm-foundation-swatch" data-color-swatch style={{ background: `var(${item.property})` }} />
<h3>{item.label}</h3><p>{item.usage}</p>
<dl><dt>CSS Variable</dt><dd><code>{item.property}</code></dd>
{item.runtimeKey && <><dt>Runtime Key</dt><dd><code>{item.runtimeKey}</code></dd></>}
<dt>Current Value</dt><dd data-current-value>{values[item.property] || "CSS 값을 읽는 중"}</dd>
<dt>{item.defaultValue ? "HM Default Value" : "Source Declaration"}</dt><dd><code>{item.defaultValue ?? item.declared}</code></dd>
{item.defaultValue && <><dt>Source Declaration</dt><dd><code>{item.declared}</code></dd></>}
<dt>Source</dt><dd>{item.source}</dd></dl>
</article>)}</div>
}
function ColorReferencePage() {
const values = useResolvedProperties(allColorProperties)
return <ReferencePage title="Color" intro="Primitive → Semantic → Component Mapping. 같은 색이라도 역할을 구분합니다. Swatch는 현재 CSS Variable을 사용하며 Theme Designer의 저장된 preview를 반영합니다.">
<nav className="hm-foundation-links" aria-label="Color sections"><a href="#primitive">Primitive</a><a href="#semantic">Semantic</a><a href="#component-mapping">Component Mapping</a></nav>
<ReferenceSection id="primitive" title="Primitive Palette" note="현재 primitives.css에 실제 정의된 색만 표시합니다. Primitive에는 공개 Runtime Key를 부여하지 않습니다."><ColorCards items={primitiveColors} values={values} /></ReferenceSection>
<ReferenceSection id="semantic" title="Semantic Role" note="공개 Runtime Key는 contract.ts에서 읽습니다. 내부 파생 CSS 값은 Runtime Key 없이 표시합니다. Gradient도 기존 HM CSS를 직접 사용합니다."><ColorCards items={semanticColors.filter(item => !item.runtimeKey?.startsWith("color.palette."))} values={values} /><details className="hm-foundation-details"><summary>Runtime Palette Aliases ({semanticColors.filter(item => item.runtimeKey?.startsWith("color.palette.")).length})</summary><ColorCards items={semanticColors.filter(item => item.runtimeKey?.startsWith("color.palette."))} values={values} /></details></ReferenceSection>
<ReferenceSection id="component-mapping" title="Component Mapping" note="대표 소비 관계입니다. 아래 변수는 내부 Component Mapping이며 공개 Runtime Key가 아닙니다. Source Declaration에서 연결된 Semantic 역할을 확인하세요."><ColorCards items={componentColors} values={values} /></ReferenceSection>
</ReferencePage>
}
const meta = { title: "Foundation/Color", parameters: { layout: "fullscreen" } } satisfies Meta
export default meta
export const All: StoryObj<typeof meta> = { name: "Color", render: () => <ColorReferencePage /> }
+28
View File
@@ -0,0 +1,28 @@
/* Storybook presentation only. All selectors stay inside the Foundation page.
Page inner, title and sections come from the common Story page (src/components/story-page.css); these rules style the reference content. */
.hm-foundation {
font-size: var(--hm-label-font-size); line-height: var(--hm-label-line-height);
font-weight: var(--hm-input-font-weight); overflow-wrap: anywhere;
}
.hm-foundation *, .hm-foundation *::before, .hm-foundation *::after { box-sizing: border-box; }
.hm-foundation h3 { margin: 0 0 8px; font-size: var(--hm-label-font-size); font-weight: var(--hm-font-weight-bold); }
.hm-foundation p { margin: 0 0 12px; }
.hm-foundation-note { color: var(--hm-sem-fg-secondary); }
.hm-foundation-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 270px), 1fr)); gap: 16px; }
.hm-foundation-card, .hm-foundation-panel { min-width: 0; padding: 16px; border: 1px solid var(--hm-sem-stroke-input-default); border-radius: var(--hm-radius-container); background: var(--hm-sem-bg-surface); }
.hm-foundation-swatch { height: 64px; margin-bottom: 16px; border: 1px solid var(--hm-sem-stroke-input-default); border-radius: var(--hm-radius-control-md); }
.hm-foundation dl { margin: 16px 0 0; }
.hm-foundation dt { margin-top: 8px; color: var(--hm-sem-fg-secondary); font-size: var(--hm-button-font-size-sm); }
.hm-foundation dd { margin: 2px 0 0; min-width: 0; }
.hm-foundation code { font-family: inherit; font-size: inherit; white-space: normal; overflow-wrap: anywhere; }
.hm-foundation-links { display: flex; flex-wrap: wrap; gap: 16px; padding-top: 16px; }
.hm-foundation-links a { color: var(--hm-sem-action-primary); text-decoration: underline; }
.hm-foundation-details { margin-top: 24px; }
.hm-foundation-details summary { cursor: pointer; padding-block: 12px; font-weight: var(--hm-font-weight-bold); }
.hm-foundation-specimens { display: grid; gap: 16px; }
.hm-foundation-specimen { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--hm-sem-stroke-input-default); }
.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-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); }
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { layoutProperties, radiusReferences, spacingReferences } from "./reference-data"
import { ReferencePage, ReferenceSection, useResolvedProperties } from "./reference"
function LayoutReferencePage() {
const values = useResolvedProperties(layoutProperties)
const [desktop, setDesktop] = useState(() => matchMedia("(min-width: 1024px)").matches)
useEffect(() => { const media = matchMedia("(min-width: 1024px)"); const update = () => setDesktop(media.matches); media.addEventListener("change", update); return () => media.removeEventListener("change", update) }, [])
return <ReferencePage title="Layout" intro="현재 HM의 responsive boundary와 컴포넌트별 layout geometry를 확인합니다. 전역 spacing scale이나 grid system을 새로 정의하지 않습니다.">
<ReferenceSection title="Responsive Boundary" note="CURRENT_STANDARD · gnb.css / lnb.css / dashboard-layout.css 및 GNB의 matchMedia 규칙에 근거합니다."><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><p role="status">현재 preview: <strong>{desktop ? "Desktop" : "Mobile"}</strong> · GNB 높이 {values["--hm-gnb-height"]} · LNB track {values["--hm-lnb-width"]}</p><p className="hm-foundation-note">CSS는 min-width: 1024px / max-width: 1023px, GNB JS는 min-width: 1024px를 사용합니다. 표시는 기존 정수 QA 경계를 따릅니다.</p></ReferenceSection>
<ReferenceSection title="QA Reference Viewports" note="1280 / 390은 검증용 viewport이며 breakpoint token이 아닙니다."><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><p className="hm-foundation-note">Boundary QA presets: 1024 × 900 / 1023 × 900</p></ReferenceSection>
<ReferenceSection title="Spacing · Current Usage Reference" note="전역 공식 spacing scale은 정의되어 있지 않습니다. 아래 변수는 각 pattern/component의 현재 사용값이며 동일한 숫자를 하나의 token으로 합치지 않습니다."><div className="hm-foundation-grid">{spacingReferences.map(([property, name, source]) => <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" /><p className="hm-foundation-note">packages/ui/src/theme/{source}</p></article>)}</div></ReferenceSection>
<ReferenceSection title="Radius" note="기존 default.css의 geometry variables입니다. 역할이 다른 같은 반경은 따로 표시합니다."><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="Layout / Container Reference" note="CURRENT_USAGE_REFERENCE · Dashboard의 가용 폭, 외곽 padding, 섹션 gap과 Card padding만 축소 specimen으로 보여줍니다."><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><p className="hm-foundation-note">Grid / Container: NOT_FORMALLY_DEFINED · 공용 column 수 / max-width token은 FOLLOW_UP. src/hm-theme.css의 .hm-container는 앱 데모의 로컬 사용 규칙입니다.</p></ReferenceSection>
</ReferencePage>
}
const meta = { title: "Foundation/Layout", parameters: { layout: "fullscreen" } } satisfies Meta
export default meta
export const All: StoryObj<typeof meta> = { name: "Layout", render: () => <LayoutReferencePage /> }
+65
View File
@@ -0,0 +1,65 @@
import { runtimeThemeContract } from "@hm-design/ui/theme"
import primitives from "../../packages/ui/src/theme/primitives.css?raw"
import defaults from "../../packages/ui/src/theme/default.css?raw"
import input from "../../packages/ui/src/theme/input.css?raw"
import checkbox from "../../packages/ui/src/theme/checkbox.css?raw"
import button from "../../packages/ui/src/theme/button.css?raw"
import card from "../../packages/ui/src/theme/card.css?raw"
// Read the existing source, rather than maintain a second palette in Storybook.
export function declarations(source: string): Record<string, string> {
return Object.fromEntries([...source.matchAll(/^\s*(--[\w-]+):\s*([^;]+);/gm)].map(match => [match[1], match[2].trim()]))
}
export const defaultDeclarations = declarations(defaults)
export type ColorReference = { property: string; label: string; source: string; declared: string; runtimeKey?: string; defaultValue?: string; usage: string }
const label = (property: string) => property.replace(/^--hm-(sem-)?/, "").replaceAll("-", " ")
const usages: Record<string, string> = {
"color.action.primary": "Primary 동작 · 현재 위치 · 내비게이션",
"color.state.active": "Checked / Selected / On 컨트롤의 면",
"color.on.state.active": "선택된 컨트롤의 글자와 아이콘",
"color.stroke.input.focus": "Input Focus · Select Open · 컨트롤 포커스 링",
"color.accent": "메뉴 · Calendar · Table 강조 면의 기준 색",
"color.bg.content": "콘텐츠 영역의 배경",
"color.bg.surface": "입력 · Card · Popup 표면",
"color.action.destructive": "오류 · Invalid · Destructive 동작",
}
export const primitiveColors: ColorReference[] = Object.entries(declarations(primitives)).map(([property, declared]) => ({ property, declared, label: label(property), source: "packages/ui/src/theme/primitives.css", usage: "색상 자체를 나타내는 내부 Primitive · Runtime Key 없음" }))
const publicByProperty = new Map(Object.entries(runtimeThemeContract).map(([key, definition]) => [definition.property as string, { key, ...definition }]))
export const semanticColors: ColorReference[] = Object.entries(defaultDeclarations).filter(([property]) => property.startsWith("--hm-sem-") && !/-(width|radius|shadow)$/.test(property)).map(([property, declared]) => {
const definition = publicByProperty.get(property)
return { property, declared, label: label(property), runtimeKey: definition?.key, defaultValue: definition?.defaultValue, source: "packages/ui/src/theme/default.css · contract.ts", usage: definition ? usages[definition.key] ?? (definition.key.startsWith("color.palette.") ? "Runtime에서 조정 가능한 palette alias · Primitive와 별도 계층" : definition.key.includes("stop.") ? "Gradient stop · 위치와 geometry는 기존 CSS가 소유" : "현재 Runtime Theme contract의 Semantic 역할") : "내부 Semantic / 파생 값 · 공개 Runtime Key 없음" }
})
const componentSources = { input, checkbox, button, card }
const mappings = [
["input", "--hm-input-background", "Input Background", "입력 표면"],
["input", "--hm-input-focus-border-color", "Input Focus Border", "공통 Focus 역할을 사용하는 입력 테두리"],
["input", "--hm-input-error-border-color", "Input Error Border", "Destructive 역할을 사용하는 오류 테두리"],
["checkbox", "--hm-checkbox-checked-background", "Checkbox Checked Background", "Active 역할을 사용하는 선택 면"],
["checkbox", "--hm-checkbox-indicator-color", "Checkbox Indicator", "On Active 역할을 사용하는 선택 아이콘"],
["button", "--hm-button-outline-green-ink", "Button Primary Outline Ink", "Primary 역할을 사용하는 Outline 글자"],
["button", "--hm-button-brand-green-background", "Button Brand Background", "기존 4-stop action surface gradient"],
["card", "--hm-card-surface", "Card Surface", "Semantic Surface를 소비하는 Card 표면"],
] as const
export const componentColors: ColorReference[] = mappings.map(([component, property, name, usage]) => ({ property, label: name, usage, source: `packages/ui/src/theme/${component}.css`, declared: declarations(componentSources[component])[property] }))
export const allColorProperties = [...primitiveColors, ...semanticColors, ...componentColors].map(item => item.property)
// These are existing component-local metrics, not new Heading / Body tokens.
export const typographyReferences = [
{ label: "Page Header · title", source: "page-header.css", size: "--hm-page-header-title-font-size", line: "--hm-page-header-title-line-height", weight: "--hm-page-header-title-font-weight" },
{ label: "Card · title", source: "card.css", size: "--hm-card-title-font-size", line: "--hm-card-title-line-height", weight: "--hm-card-title-font-weight" },
{ label: "Page Header · description", source: "page-header.css", size: "--hm-page-header-description-font-size", line: "--hm-page-header-description-line-height" },
{ label: "Label", source: "label.css", size: "--hm-label-font-size", line: "--hm-label-line-height", weight: "--hm-label-font-weight" },
...["sm", "md", "lg"].map(size => ({ label: `Input · ${size}`, source: "input.css", size: `--hm-input-font-size-${size}`, line: `--hm-input-line-height-${size}`, weight: "--hm-input-font-weight" })),
...["sm", "md", "lg"].map(size => ({ label: `Button Standard · ${size}`, source: "button.css", size: `--hm-button-font-size-${size}`, line: `--hm-button-line-height-${size}`, weight: "--hm-button-weight-standard" })),
]
export const typographyProperties = ["--hm-font-family-base", "--hm-font-weight-medium", "--hm-font-weight-bold", "--hm-card-title-font-weight", "--hm-input-font-weight", ...typographyReferences.flatMap(item => [item.size, item.line, ...(item.weight ? [item.weight] : [])])]
export const spacingReferences = [
["--hm-dashboard-padding", "Dashboard 외곽 여백", "dashboard-layout.css · Desktop 34px / Mobile 20px"],
["--hm-dashboard-gap", "Dashboard 섹션 사이", "dashboard-layout.css"],
["--hm-card-padding", "Card 안쪽 여백", "card.css"],
["--hm-page-header-margin-bottom", "Page Header → 첫 콘텐츠", "page-header.css"],
["--hm-gnb-join", "GNB → 콘텐츠 연결", "gnb.css"],
["--hm-lnb-join", "LNB → 콘텐츠 연결", "lnb.css"],
] as const
export const radiusReferences = ["--hm-radius-control-md", "--hm-radius-button", "--hm-radius-input", "--hm-radius-select", "--hm-radius-container"]
export const layoutProperties = [...spacingReferences.map(([property]) => property), ...radiusReferences, "--hm-gnb-height", "--hm-lnb-width"]
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useRef, useState, type ReactNode } from "react"
import { STORY_KICKER, StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import "./foundation.css"
export function useResolvedProperties(properties: readonly string[]) {
const [values, setValues] = useState<Record<string, string>>({})
useEffect(() => {
let frame = 0
const read = () => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(() => {
const style = getComputedStyle(document.documentElement)
const next = Object.fromEntries(properties.map(property => [property, style.getPropertyValue(property).trim()]))
setValues(previous => properties.every(property => previous[property] === next[property]) ? previous : next)
})
}
read()
const observer = new MutationObserver(read)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "class", "data-hm-theme"] })
window.addEventListener("resize", read)
return () => { observer.disconnect(); window.removeEventListener("resize", read); cancelAnimationFrame(frame) }
}, [properties])
return values
}
// The page frame, title and sections are the common Story page (src/components/story-page.tsx); .hm-foundation styles the reference content.
export function ReferencePage({ title, intro, children }: { title: string; intro: string; children: ReactNode }) {
return <StoryPage className="hm-foundation"><StoryPageHeader kicker={STORY_KICKER.foundation} title={title} description={intro} />{children}</StoryPage>
}
export function ReferenceSection({ title, note, children, id }: { title: string; note: string; children: ReactNode; id?: string }) {
return <StorySection id={id} title={title} description={note}>{children}</StorySection>
}
export function Specimen({ label, size, line, weight }: { label: string; size: string; line: string; weight?: string }) {
const ref = useRef<HTMLDivElement>(null)
const [metrics, setMetrics] = useState("")
useEffect(() => {
let mounted = true
void document.fonts.ready.then(() => {
if (!mounted || !ref.current) return
const style = getComputedStyle(ref.current)
setMetrics(`${style.fontSize} / ${style.lineHeight} / ${style.fontWeight} · letter-spacing: ${style.letterSpacing}`)
})
return () => { mounted = false }
}, [])
return <div className="hm-foundation-specimen" ref={ref} data-typography-sample={label} style={{ fontSize: `var(${size})`, lineHeight: `var(${line})`, fontWeight: weight ? `var(${weight})` : "normal" }}><p lang="ko">가나다라마바사 아자차카타파하 · 한맥 프로젝트</p><p lang="en">ABCDEFGHIJKLMNOPQRSTUVWXYZ<br />abcdefghijklmnopqrstuvwxyz</p><p>0123456789</p><small className="hm-foundation-note" data-typography-metrics>{metrics}</small></div>
}
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { typographyProperties, typographyReferences } from "./reference-data"
import { ReferencePage, ReferenceSection, Specimen, useResolvedProperties } from "./reference"
const weights = ["--hm-input-font-weight", "--hm-font-weight-medium", "--hm-card-title-font-weight", "--hm-font-weight-bold"]
function TypographyReferencePage() {
const values = useResolvedProperties(typographyProperties)
return <ReferencePage title="Typography" intro="Current Typography Reference · 기존 HM 컴포넌트의 실제 구현 규격입니다. 새로운 Heading / Body / Caption token 체계를 정의하지 않습니다.">
<ReferenceSection title="Font Family" note="default.css의 실제 Font stack과 theme.css의 로컬 Font asset을 사용합니다."><div className="hm-foundation-panel"><code>--hm-font-family-base</code><p>{values["--hm-font-family-base"]}</p><p>Noto Sans KR · /fonts/NotoSansKR-variable.ttf · font-weight 100–900 asset</p><p className="hm-foundation-note">Asset의 지원 범위와 아래 컴포넌트의 실제 사용 굵기는 별도로 표시합니다.</p></div></ReferenceSection>
<ReferenceSection title="Font Weight" note="400(Input) · 500(Medium / Standard Button) · 600(Card title) · 700(Bold). 같은 글자열로 현재 굵기를 비교합니다."><div className="hm-foundation-grid">{weights.map(property => <article className="hm-foundation-card" key={property}><code>{property}</code><p>{values[property]}</p><p style={{ fontWeight: `var(${property})` }}>한맥 프로젝트<br />Hanmac Project<br />0123456789</p></article>)}</div></ReferenceSection>
<ReferenceSection title="Component Specimens" note="크기 / 행높이 / 굵기 / 자간은 specimen에서 computed style로 읽습니다. 자간을 별도 지정하지 않은 컴포넌트는 현재 normal을 유지합니다."><div className="hm-foundation-specimens">{typographyReferences.map(item => <article className="hm-foundation-panel" key={item.label}><h3>{item.label}</h3><p className="hm-foundation-note">packages/ui/src/theme/{item.source}</p><dl><dt>Size</dt><dd><code>{item.size}</code> · {values[item.size]}</dd><dt>Line Height</dt><dd><code>{item.line}</code> · {values[item.line]}</dd><dt>Weight</dt><dd>{item.weight ? <><code>{item.weight}</code> · {values[item.weight]}</> : "normal · 컴포넌트에서 별도 weight 지정 없음"}</dd></dl><Specimen {...item} /></article>)}</div></ReferenceSection>
</ReferencePage>
}
const meta = { title: "Foundation/Typography", parameters: { layout: "fullscreen" } } satisfies Meta
export default meta
export const All: StoryObj<typeof meta> = { name: "Typography", render: () => <TypographyReferencePage /> }