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

+248
View File
@@ -0,0 +1,248 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { defaultRuntimeTheme, runtimeThemeContract, fetchRuntimeTheme, type RuntimeThemeToken } from "@hm-design/ui/theme"
import { designerThemeStore } from "./designer-preview"
import { Specimens } from "./specimens"
import { DesignerExtras } from "./designer-extras"
import { StoryPage } from "@/components/story-page"
import "./designer-theme.css"
const roles = [
["color.action.primary", "Primary / Brand", "Badge · 현재 페이지 · 내비게이션"],
["color.state.active", "Active / Selected", "Checkbox · Radio · Switch · Calendar"],
["color.on.state.active", "On Active", "선택된 컨트롤의 아이콘과 글자"],
["color.stroke.input.focus", "Focus", "입력 테두리 · Checkbox / Calendar 포커스 링"],
["color.accent", "Accent", "메뉴 · Calendar · Table · Pagination의 45% 강조 면"],
["color.on.accent", "On Accent", "강조 면의 글자"],
["color.action.destructive", "Destructive", "오류 · Invalid · Destructive Button / Badge"],
["color.bg.surface", "Surface", "입력 · Card · Popup"],
["color.bg.content", "Content Surface", "콘텐츠 영역"],
["color.bg.subtle", "Subtle Surface", "은은한 면 · Calendar today"],
["color.fg.primary", "Text Primary", "주요 텍스트"],
["color.fg.secondary", "Text Secondary", "보조 텍스트"],
["color.fg.placeholder", "Placeholder", "입력 안내"],
["color.fg.inverse", "Inverse Text", "반전 면의 텍스트"],
["color.stroke.default", "Default Border", "컨트롤 · Badge 외곽선"],
["color.stroke.input.default", "Input Border", "입력 · Popup 테두리"],
["color.action.cancel", "Cancel", "취소 동작"],
] as const satisfies readonly (readonly [RuntimeThemeToken, string, string])[]
function ColorField({ token, label, hint, value, onChange }: { token: RuntimeThemeToken; label: string; hint: string; value: string; onChange: (value: string) => void }) {
const [draft, setDraft] = useState(value)
const [previous, setPrevious] = useState(value)
if (previous !== value) { setPrevious(value); setDraft(value) }
const valid = /^#[\da-f]{6}$/i.test(draft)
const update = (next: string) => { setDraft(next); if (/^#[\da-f]{6}$/i.test(next)) onChange(next.toLowerCase()) }
return <div className="hm-designer-field">
<label htmlFor={`theme-${token}`}>{label}<small>{hint}</small></label>
<div className="hm-designer-inputs">
<input type="color" value={value} aria-label={`${label} color picker`} onChange={event => update(event.target.value)} />
<input id={`theme-${token}`} aria-label={`${label} HEX`} aria-invalid={!valid} spellCheck={false} value={draft} onChange={event => update(event.target.value)} />
</div>
{!valid && <small role="status">#123abc 형식의 6자리 HEX를 입력하세요. 마지막 유효값을 유지합니다.</small>}
</div>
}
function ThemeEditor() {
const [navigationMode, setNavigationMode] = useState<"GNB" | "LNB">("GNB")
const theme = useSyncExternalStore(designerThemeStore.subscribe, designerThemeStore.getSnapshot)
const [json, setJson] = useState("")
const [status, setStatus] = useState("")
const [themeUrl, setThemeUrl] = useState("/themes/service-example.json")
const [loading, setLoading] = useState(false)
const pending = useRef<AbortController | undefined>(undefined)
useEffect(() => () => pending.current?.abort(), [])
const [revision, setRevision] = useState(0)
const [effective, setEffective] = useState(defaultRuntimeTheme.tokens)
useEffect(() => {
// Read resolved roles after the preview decorator's layout effect.
// JSON Default + partial override is what the components actually consume.
const style = getComputedStyle(document.documentElement)
setEffective(Object.fromEntries(roles.map(([token]) => {
const value = style.getPropertyValue(runtimeThemeContract[token].property).trim()
// CSS minification may shorten #ffffff to #fff; the public input stays HEX6.
return [token, /^#[\da-f]{3}$/i.test(value) ? `#${value.slice(1).split("").map(character => character.repeat(2)).join("")}` : value]
})))
}, [theme])
const exported = JSON.stringify(theme, null, 2)
const cancelUrl = () => { pending.current?.abort(); pending.current = undefined; setLoading(false) }
const updateTheme = (value: unknown) => {
const accepted = designerThemeStore.set(value)
if (accepted) cancelUrl()
return accepted
}
const loadUrl = async (url: string) => {
pending.current?.abort()
const controller = new AbortController()
pending.current = controller
setLoading(true)
try {
const fetched = await fetchRuntimeTheme(url, { cache: "no-store", signal: controller.signal })
if (pending.current !== controller) return
if (!designerThemeStore.set(fetched)) throw Error("Expected color-only Theme")
setRevision(value => value + 1)
setStatus(`Theme JSON 적용: ${url}`)
} catch { if (pending.current === controller) { designerThemeStore.reset(); setRevision(value => value + 1); setStatus("Theme URL 로드 실패: HM Default로 복원했습니다.") } }
finally { if (pending.current === controller) { pending.current = undefined; setLoading(false) } }
}
const reset = () => { cancelUrl(); designerThemeStore.reset(); setRevision(value => value + 1); setJson(""); setStatus("HM Default로 즉시 복원했습니다.") }
const importTheme = () => {
try {
if (!updateTheme(JSON.parse(json))) throw Error("invalid")
setRevision(value => value + 1)
setStatus("Theme JSON을 즉시 적용했습니다.")
} catch { setStatus("가져오기 실패: schemaVersion 1과 공개 컬러 token / 6자리 HEX를 확인하세요. Radius는 이 컬러 편집기에서 허용하지 않습니다. 기존 Theme를 유지합니다.") }
}
const copy = async () => {
setJson(exported)
try { await navigator.clipboard.writeText(exported); setStatus("JSON을 복사했습니다. 개발팀에 전달하세요.") }
catch { setStatus("클립보드 접근이 제한되어 있습니다. JSON 입력란에서 직접 복사하세요.") }
}
const download = () => {
const url = URL.createObjectURL(new Blob([exported], { type: "application/json" }))
const link = document.createElement("a")
link.href = url; link.download = "hm-color-theme.json"; link.hidden = true
document.body.append(link); link.click(); link.remove()
// Give the browser download handler time to acquire the Blob before release.
setTimeout(() => URL.revokeObjectURL(url), 1000)
setStatus("hm-color-theme.json 다운로드를 요청했습니다. 다운로드가 제한되면 Show JSON으로 직접 복사하세요.")
}
return <StoryPage><main className="hm-designer">
<header className="hm-designer-header">
<div><p>HM DESIGN SYSTEM / COLOR THEME</p><h1>디자인팀 Theme Playground</h1>
<p>컬러를 바꾸면 이 화면과 다른 Story의 UI에 즉시 반영됩니다. 현재 탭의 세션 동안 유지됩니다.</p></div>
<button type="button" onClick={reset}>Reset to HM Default</button>
</header>
<div className="hm-designer-layout">
<section className="hm-designer-editor" aria-label="Theme color editor">
<section className="hm-designer-source" aria-label="Theme JSON source">
<h2>Theme JSON Preview</h2><p>원본은 themes/*.json입니다. 파일을 Import하거나 서비스의 CDN URL을 입력하세요.</p>
<div className="hm-designer-actions"><button type="button" disabled={loading} onClick={() => void loadUrl("/themes/default.json")}>Load Default JSON</button>
<button type="button" disabled={loading} onClick={() => void loadUrl("/themes/service-example.json")}>Load Service Example JSON</button></div>
<label htmlFor="hm-theme-url">Theme JSON URL</label><input id="hm-theme-url" value={themeUrl} onChange={event => setThemeUrl(event.target.value)} />
<button type="button" disabled={loading || !themeUrl.trim()} onClick={() => void loadUrl(themeUrl)}>Load URL</button>
</section>
<h2>Theme 역할 편집</h2><p>HM Default를 출발점으로 편집합니다. 유효한 HEX를 입력하면 바로 적용됩니다.</p>
{roles.map(([token, label, hint]) => <ColorField key={`${token}:${revision}`} token={token} label={label} hint={hint}
value={effective[token] ?? defaultRuntimeTheme.tokens[token]!}
onChange={value => updateTheme({ schemaVersion: 1, tokens: { ...theme.tokens, [token]: value } })} />)}
<section className="hm-designer-transfer" aria-label="Theme JSON transfer">
<h2>개발팀에 전달</h2><p>공식 Runtime Contract의 JSON입니다. 수정한 역할만 내보내며 나머지는 HM Default를 사용합니다.</p>
<div className="hm-designer-actions"><button type="button" onClick={() => { setJson(exported); setStatus("현재 Theme JSON을 표시했습니다.") }}>Show JSON</button>
<button type="button" onClick={() => void copy()}>Copy JSON</button><button type="button" onClick={download}>Export JSON</button></div>
<textarea aria-label="Theme JSON" value={json} onChange={event => setJson(event.target.value)} placeholder={'{"schemaVersion":1,"tokens":{}}'} rows={8} spellCheck={false} />
<button type="button" onClick={importTheme}>Import JSON</button>
<input type="file" accept=".json,application/json" aria-label="Import Theme JSON file" onChange={event => {
const file = event.target.files?.[0]
if (file) void file.text().then(text => {
setJson(text)
try { if (!updateTheme(JSON.parse(text))) throw Error("invalid"); setRevision(value => value + 1); setStatus("Theme 파일을 즉시 적용했습니다.") }
catch { setStatus("Theme 파일이 유효하지 않습니다. 기존 Theme를 유지합니다.") }
}).catch(() => setStatus("Theme 파일을 읽지 못했습니다."))
event.target.value = ""
}} />
<output aria-live="polite">{status}</output>
</section>
</section>
<section className="hm-designer-preview" aria-label="Live component preview"><h2>Live Preview</h2>
<p>Primary · Active · Focus · Accent · Destructive의 영향을 여러 컴포넌트에서 함께 확인하세요.</p>
<label htmlFor="hm-preview-navigation-mode">Navigation Mode</label>
<select id="hm-preview-navigation-mode" value={navigationMode} onChange={event => setNavigationMode(event.target.value === "LNB" ? "LNB" : "GNB")}>
<option value="GNB">GNB ONLY</option><option value="LNB">LNB ONLY</option>
</select>
<Specimens navigationMode={navigationMode} /><DesignerExtras />
</section>
</div>
</main></StoryPage>
}
const meta = { title: "Theme/Designer Playground", parameters: { layout: "fullscreen" } } satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
export const Editor: Story = { name: "Designer Playground", render: () => <ThemeEditor /> }
export const LiveVerification: Story = {
tags: ["verification"],
render: () => <><output data-testid="designer-result">Ready</output><ThemeEditor /></>,
play: async ({ canvasElement }) => {
const result = canvasElement.querySelector<HTMLOutputElement>('[data-testid="designer-result"]')!
const preview = canvasElement.querySelector<HTMLElement>('[aria-label="Live component preview"]')!
const origin = performance.timeOrigin
const checks: string[] = []
const assert = (ok: boolean, label: string) => { if (!ok) throw Error(label); checks.push(label) }
const settle = () => new Promise(resolve => setTimeout(resolve, 250))
const variable = (property: string) => getComputedStyle(document.documentElement).getPropertyValue(property).trim().toLowerCase()
const style = (selector: string, property: "backgroundColor" | "color" | "borderTopColor") => getComputedStyle(preview.querySelector<HTMLElement>(selector)!)[property]
const geometry = () => Array.from(preview.querySelectorAll<HTMLElement>("[data-slot]")).map(element => {
const style = getComputedStyle(element)
return [element.dataset.slot, ...["width", "height", "padding", "margin", "borderRadius", "gap", "fontSize", "lineHeight", "display"].map(key => style[key as keyof CSSStyleDeclaration])]
})
const setInput = (label: string, value: string) => {
const input = canvasElement.querySelector<HTMLInputElement>(`[aria-label="${label}"]`)!
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value)
input.dispatchEvent(new Event("input", { bubbles: true }))
}
const click = (label: string) => Array.from(canvasElement.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent === label)!.click()
try {
result.textContent = "Running designer workflow verification…"
await document.fonts.ready
click("Reset to HM Default"); await settle()
const initial = JSON.stringify(geometry())
for (const [token, label] of roles) {
assert(canvasElement.querySelector<HTMLInputElement>(`[aria-label="${label} HEX"]`)!.value === defaultRuntimeTheme.tokens[token], `${label}: auto-loaded HM Default`)
}
const core = [
["Primary / Brand", "--hm-sem-action-primary", "#6837a8"],
["Active / Selected", "--hm-sem-state-active", "#007f73"],
["Focus", "--hm-sem-stroke-input-focus", "#c75b00"],
["Accent", "--hm-sem-accent", "#ffd36d"],
["Destructive", "--hm-sem-action-destructive", "#d41466"],
] as const
for (const [label, property, value] of core) {
const others = core.filter(row => row[1] !== property).map(row => [row[1], variable(row[1])])
setInput(`${label} HEX`, value); await settle()
assert(variable(property) === value, `${label}: input applies live`)
if (label === "Primary / Brand") assert(canvasElement.querySelector<HTMLInputElement>('[aria-label="On Accent HEX"]')!.value === variable("--hm-sem-on-accent"), "On Accent displays its actual resolved role")
for (const [key, previous] of others) assert(variable(key) === previous, `${label}: leaves ${key} independent`)
assert(JSON.stringify(geometry()) === initial, `${label}: component geometry unchanged`)
assert(performance.timeOrigin === origin && preview.isConnected, `${label}: same document, reload 0`)
}
assert(style('[data-slot="checkbox"][aria-label="Checked"]', "backgroundColor") === "rgb(0, 127, 115)", "Checkbox fill uses Active")
assert(style('[data-slot="radio-group-item"]', "borderTopColor") === "rgb(0, 127, 115)", "Radio uses Active")
assert(style('[data-slot="switch"]', "backgroundColor") === "rgb(0, 127, 115)", "Switch uses Active")
assert(style('[data-slot="slider-range"]', "backgroundColor") === "rgb(0, 127, 115)", "Slider uses Active")
assert(style('[data-slot="progress-indicator"]', "backgroundColor") === "rgb(0, 127, 115)", "Progress uses Active")
assert(style('[data-slot="badge"]', "backgroundColor") === "rgb(104, 55, 168)", "Badge uses Primary")
assert(style('[data-slot="pagination-link"][data-active="true"]', "backgroundColor") === "rgb(104, 55, 168)", "Pagination current uses Primary")
for (const property of ["--hm-select-option-state-background", "--hm-calendar-day-hover-background", "--hm-calendar-range-middle-background", "--hm-pagination-hover-background", "--hm-table-row-hover-background", "--hm-table-row-selected-background", "--hm-command-highlight-background", "--hm-dropdown-menu-highlight-background", "--hm-context-menu-highlight-background", "--hm-menubar-highlight-background"])
assert(variable(property).includes("#ffd36d 45%"), `${property}: live Accent 45%`)
click("Show JSON")
await settle()
const transfer = canvasElement.querySelector<HTMLTextAreaElement>('[aria-label="Theme JSON"]')!
const exported = transfer.value
assert(JSON.parse(exported).tokens["color.state.active"] === "#007f73", "Export is official partial contract JSON")
click("Reset to HM Default"); await settle()
assert(JSON.stringify(geometry()) === initial && variable("--hm-sem-state-active") === "#266651", "Reset restores HM Default immediately")
const fillJson = (value: string) => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(transfer, value)
transfer.dispatchEvent(new Event("input", { bubbles: true }))
}
fillJson(exported); await settle(); click("Import JSON"); await settle()
assert(variable("--hm-sem-state-active") === "#007f73" && variable("--hm-sem-stroke-input-focus") === "#c75b00", "Export -> Import round trip applies immediately")
for (const invalid of ["not json", '{"schemaVersion":1,"tokens":{"color.action.primary":"red"}}', '{"schemaVersion":1,"tokens":{"radius.input.default":"7px"}}', '{"schemaVersion":1,"tokens":{"--hm-checkbox-checked-background":"#123456"}}']) {
fillJson(invalid); await settle(); click("Import JSON"); await settle()
assert(variable("--hm-sem-state-active") === "#007f73", "Invalid/color-only import preserves last valid Theme")
}
fillJson('{"schemaVersion":1,"tokens":{"color.state.active":"#345678"}}'); await settle(); click("Import JSON"); await settle()
assert(variable("--hm-sem-state-active") === "#345678" && variable("--hm-sem-action-primary") === "#123328" && variable("--hm-sem-stroke-input-focus") === "#266651", "Partial import replaces previous keys; unspecified roles use Default")
for (const [index, [token, label]] of roles.entries()) {
const value = `#3456${(120 + index).toString(16)}`
setInput(`${label} HEX`, value); await settle()
assert(designerThemeStore.getSnapshot().tokens[token] === value, `${label}: editor role works`)
}
assert(JSON.stringify(geometry()) === initial, "All color roles leave component layout unchanged")
result.textContent = `PASS — ${checks.length} designer checks; viewport ${window.innerWidth}; live input, role independence, default load, reset, partial, import/export, layout, reload 0.`
} catch (error) { result.textContent = `FAIL after ${checks.length} checks: ${String(error)}`; throw error }
finally { designerThemeStore.reset() }
},
}