Initial commit: HMDesignSystem baseline
This commit is contained in:
commit
b6a0a61103
486 files changed
+68432
No files matched your search
@@ -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() }
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user