import { useEffect, useRef, useState, useSyncExternalStore } from "react" import { flushSync } from "react-dom" 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 { VerificationStyles, waitForThemeState } from "./verification-state" 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 · Pagination 45%, Table 행 20% 강조 면"], ["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
update(event.target.value)} /> update(event.target.value)} />
{!valid && #123abc 형식의 6자리 HEX를 입력하세요. 마지막 유효값을 유지합니다.}
} 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(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

HM DESIGN SYSTEM / COLOR THEME

디자인팀 Theme Playground

임시 컬러는 이 편집기와 실시간 미리보기에만 적용됩니다. 편집값은 현재 탭의 세션 동안 보관하며 다른 Story는 HM Default로 검수합니다.

Theme JSON 미리보기

원본은 themes/*.json입니다. 파일을 Import하거나 서비스의 CDN URL을 입력하세요.

setThemeUrl(event.target.value)} />

Theme 역할 편집

HM Default를 출발점으로 편집합니다. 유효한 HEX를 입력하면 바로 적용됩니다.

{roles.map(([token, label, hint]) => updateTheme({ schemaVersion: 1, tokens: { ...theme.tokens, [token]: value } })} />)}

개발팀에 전달

공식 Runtime Contract의 JSON입니다. 수정한 역할만 내보내며 나머지는 HM Default를 사용합니다.