feat: integrate latest HMDesign design system updates

This commit is contained in:
DESKTOP-KPCP6AL\ㅅㅎ committed 2026-10-11 11:09:03 +09:00
1 parent 99faec189e
commit 1c4e0bb9a1
209 files changed
+8845 -552

No files matched your search

+25 -18
View File
@@ -1,10 +1,12 @@
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 = [
@@ -12,7 +14,7 @@ const roles = [
["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.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"],
@@ -110,7 +112,7 @@ function ThemeEditor() {
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>
<p>임시 컬러는 이 편집기와 Live Preview에만 적용됩니다. 편집값은 현재 탭의 세션 동안 보관하며 다른 Story는 HM Default로 검수합니다.</p></div>
<button type="button" onClick={reset}>Reset to HM Default</button>
</header>
<div className="hm-designer-layout">
@@ -156,21 +158,20 @@ function ThemeEditor() {
</main></StoryPage>
}
const meta = { title: "Theme/Designer Playground", parameters: { layout: "fullscreen" } } satisfies Meta
const meta = { title: "Theme/Designer Playground", tags: ["internal"], 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 /></>,
render: () => <div data-hm-theme-verification><VerificationStyles /><output data-testid="designer-result">Ready</output><ThemeEditor /></div>,
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 => {
@@ -180,13 +181,14 @@ export const LiveVerification: Story = {
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 }))
flushSync(() => input.dispatchEvent(new Event("input", { bubbles: true })))
}
const click = (label: string) => Array.from(canvasElement.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent === label)!.click()
const click = (label: string) => flushSync(() => 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()
click("Reset to HM Default")
await waitForThemeState(() => roles.every(([token, label]) => canvasElement.querySelector<HTMLInputElement>(`[aria-label="${label} HEX"]`)?.value === defaultRuntimeTheme.tokens[token]), "Designer default inputs")
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`)
@@ -200,14 +202,17 @@ export const LiveVerification: Story = {
] 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()
setInput(`${label} HEX`, value)
await waitForThemeState(() => variable(property) === value, `${label}: live input`)
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")
const checked = preview.querySelector<HTMLElement>('[data-slot="checkbox"][aria-label="Checked"]')!
await waitForThemeState(() => checked.dataset.state === "checked" && checked.getAttribute("aria-checked") === "true" && style('[data-slot="checkbox"][aria-label="Checked"]', "backgroundColor") === "rgb(0, 127, 115)", "Designer checked Checkbox fill")
assert(checked.dataset.state === "checked" && checked.getAttribute("aria-checked") === "true" && 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")
@@ -215,29 +220,31 @@ export const LiveVerification: Story = {
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%`)
assert(variable(property).includes(`#ffd36d ${property.startsWith("--hm-table-row") ? 20 : 45}%`), `${property}: live Accent at its component opacity`)
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()
click("Reset to HM Default")
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 }))
flushSync(() => transfer.dispatchEvent(new Event("input", { bubbles: true })))
}
fillJson(exported); await settle(); click("Import JSON"); await settle()
fillJson(exported); click("Import JSON")
await waitForThemeState(() => variable("--hm-sem-state-active") === "#007f73", "Designer JSON import")
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()
fillJson(invalid); click("Import JSON")
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()
fillJson('{"schemaVersion":1,"tokens":{"color.state.active":"#345678"}}'); click("Import JSON")
await waitForThemeState(() => variable("--hm-sem-state-active") === "#345678", "Designer partial import")
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()
setInput(`${label} HEX`, value)
await waitForThemeState(() => designerThemeStore.getSnapshot().tokens[token] === value, `${label}: editor role`)
assert(designerThemeStore.getSnapshot().tokens[token] === value, `${label}: editor role works`)
}
assert(JSON.stringify(geometry()) === initial, "All color roles leave component layout unchanged")
+7 -6
View File
@@ -2,12 +2,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
import { applyRuntimeTheme, resetRuntimeTheme, fetchRuntimeTheme, loadRuntimeTheme, resolveRuntimeTheme, defaultRuntimeTheme, runtimeThemeContract } from "@hm-design/ui/theme"
import { Specimens } from "./specimens"
import { DesignerExtras } from "./designer-extras"
import { VerificationStyles, waitForThemeState } from "./verification-state"
const meta = { title: "Theme/Runtime Contract/Loader", tags: ["verification"], parameters: { layout: "fullscreen" } } satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
export const CdnVerification: Story = {
render: () => <main>
render: () => <main data-hm-theme-verification>
<VerificationStyles />
<h1>Runtime JSON Loader verification</h1>
<button onClick={event => void verifyLoader(event.currentTarget.parentElement!).catch(() => undefined)}>Run loader verification</button>
<output data-testid="loader-result">Ready</output>
@@ -28,25 +30,24 @@ async function verifyLoader(container: HTMLElement) {
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 settle = () => new Promise(resolve => setTimeout(resolve, 250))
const dataUrl = (value: unknown) => `data:application/json,${encodeURIComponent(JSON.stringify(value))}`
const originalFetch = globalThis.fetch
try {
result.textContent = "Running JSON Loader verification…"
await document.fonts.ready
resetRuntimeTheme(root); await settle()
resetRuntimeTheme(root)
const initial = JSON.stringify(geometry())
const origin = performance.timeOrigin
const defaults = await fetchRuntimeTheme("/themes/default.json", { cache: "no-store" })
assert(Object.keys(defaults.tokens).length === 71, "Default JSON exposes 71 color contract entries")
for (const [key, value] of Object.entries(defaults.tokens)) assert(defaultRuntimeTheme.tokens[key as keyof typeof runtimeThemeContract] === value, `${key}: JSON equals generated package fallback`)
assert(await loadRuntimeTheme(root, "/themes/default.json", { request: { cache: "no-store" } }), "Default JSON uses official URL Loader")
await settle()
assert(JSON.stringify(geometry()) === initial, "Default JSON preserves component geometry")
assert(await loadRuntimeTheme(root, "/themes/service-example.json", { request: { cache: "no-store" } }), "External partial JSON uses same URL Loader")
await settle()
for (const [property, value] of [["--hm-sem-action-primary", "#6837a8"], ["--hm-sem-state-active", "#007f73"], ["--hm-sem-stroke-input-focus", "#c75b00"], ["--hm-sem-accent", "#ffd36d"], ["--hm-sem-action-destructive", "#d41466"]]) assert(variable(property) === value, `${property}: independent external role`)
assert(getComputedStyle(container.querySelector<HTMLElement>('[data-slot="checkbox"][aria-label="Checked"]')!).backgroundColor === "rgb(0, 127, 115)", "HTTP JSON reaches rendered Checkbox")
const checked = container.querySelector<HTMLElement>('[data-slot="checkbox"][aria-label="Checked"]')!
await waitForThemeState(() => checked.dataset.state === "checked" && checked.getAttribute("aria-checked") === "true" && getComputedStyle(checked).backgroundColor === "rgb(0, 127, 115)", "HTTP JSON reaches checked Checkbox")
assert(checked.dataset.state === "checked" && checked.getAttribute("aria-checked") === "true" && getComputedStyle(checked).backgroundColor === "rgb(0, 127, 115)", "HTTP JSON reaches rendered Checkbox")
assert(JSON.stringify(geometry()) === initial, "External JSON preserves component geometry")
const resolved = resolveRuntimeTheme({ schemaVersion: 1, tokens: { "color.state.active": "#345678" } }, defaults)
assert(resolved.tokens["color.action.primary"] === "#123328" && resolved.tokens["color.state.active"] === "#345678", "Default JSON + partial service override merge")
+28 -11
View File
@@ -66,7 +66,8 @@ const shadowHasColor = (shadow: string, hex: string) => {
const subscribeViewport = (notify: () => void) => { window.addEventListener("resize", notify); return () => window.removeEventListener("resize", notify) }
function BaselineSpecimens() {
const mobile = useSyncExternalStore(subscribeViewport, () => window.matchMedia("(max-width: 1023px)").matches)
const values = mobile ? { ...baseline, "--hm-gnb-height": "56px", "--hm-gnb-padding-x": "16px", "--hm-lnb-width": "0px", "--hm-dashboard-padding": "20px" } : baseline
const reviewed = { ...baseline, "--hm-table-row-hover-background": "color-mix(in srgb, var(--hm-sem-accent) 20%, transparent)", "--hm-table-row-selected-background": "color-mix(in srgb, var(--hm-sem-accent) 20%, transparent)", "--hm-lnb-group-open-background": "color-mix(in srgb, var(--hm-sem-palette-black) 8%, transparent)" }
const values = mobile ? { ...reviewed, "--hm-gnb-height": "56px", "--hm-gnb-padding-x": "16px", "--hm-lnb-width": "0px", "--hm-dashboard-padding": "20px" } : reviewed
return <div data-testid="before" data-hm-theme="baseline" style={values as CSSProperties}><Specimens /></div>
}
@@ -75,7 +76,8 @@ export const Verification: Story = {
{/* Compare settled state targets, independent of background-tab animation clocks. */}
<style>{'[data-testid="before"] *, [data-testid="after"] * { transition-duration: 0s !important; }'}</style>
<h1 style={{ padding: 20 }}>Runtime Theme integration verification</h1>
<button style={{ margin: "0 20px" }} onClick={event => { void verifyRuntime(event.currentTarget.parentElement!).catch(() => undefined) }}>Run verification</button>
<p style={{ padding: "0 20px" }}>키보드 Focus는 Tab으로 아래 버튼에 이동한 뒤 Enter 또는 Space로 실행하세요. 포인터·자동 실행에서는 KEYBOARD_FOCUS: NOT VERIFIED로 표시합니다.</p>
<button style={{ margin: "0 20px" }} onClick={event => { void verifyRuntime(event.currentTarget.parentElement!, event.detail === 0 && event.currentTarget.matches(":focus-visible")).catch(() => undefined) }}>Run verification</button>
<output data-testid="result" style={{ display: "block", padding: 20 }}>Ready — Run verification</output>
<BaselineSpecimens />
<div data-testid="after" data-hm-theme="preset"><Specimens /></div>
@@ -83,12 +85,17 @@ export const Verification: Story = {
play: async ({ canvasElement }) => verifyRuntime(canvasElement),
}
async function verifyRuntime(canvasElement: HTMLElement) {
async function verifyRuntime(canvasElement: HTMLElement, keyboardFocus = false) {
const before = canvasElement.querySelector<HTMLElement>('[data-testid="before"]')!
const after = canvasElement.querySelector<HTMLElement>('[data-testid="after"]')!
const result = canvasElement.querySelector<HTMLOutputElement>('output')!
if (result.dataset.running === "true") return
result.dataset.running = "true"
// Compare neutral specimens regardless of the user's previous focus or Tab
// navigation during asynchronous measurement. Focus is tested separately.
const previousInert = [before.inert, after.inert]
before.inert = true
after.inert = true
const checks: string[] = []
const assert = (condition: boolean, label: string) => { if (!condition) throw Error(label); checks.push(label) }
const variable = (name: string) => {
@@ -128,14 +135,24 @@ async function verifyRuntime(canvasElement: HTMLElement) {
assert(getComputedStyle(checked).backgroundColor === "rgb(0, 127, 115)", "Rendered Checkbox uses Active")
assert(getComputedStyle(badge).backgroundColor === "rgb(104, 55, 168)", "Rendered Badge uses Primary")
assert(getComputedStyle(selectedDay).backgroundColor === "rgb(0, 127, 115)", "Rendered Calendar selected uses Active")
checked.focus()
assert(shadowHasColor(getComputedStyle(checked).boxShadow, "#c75b00"), "Actual Checkbox focus uses independent Focus")
selectedDay.focus()
assert(shadowHasColor(getComputedStyle(selectedDay).boxShadow, "#c75b00"), "Actual Calendar focus uses independent Focus")
selectedDay.blur()
if (keyboardFocus) {
after.inert = false
checked.focus()
assert(checked.matches(":focus-visible") && shadowHasColor(getComputedStyle(checked).boxShadow, "#c75b00"), "Keyboard Checkbox focus uses independent Focus")
selectedDay.focus()
assert(selectedDay.matches(":focus-visible") && shadowHasColor(getComputedStyle(selectedDay).boxShadow, "#c75b00"), "Keyboard Calendar focus uses independent Focus")
selectedDay.blur()
after.inert = true
}
await settle(after)
assert(variable("--hm-sem-bg-surface") === "#ffffff", "Partial override preserves unspecified Default")
for (const key of ["--hm-select-option-state-background", "--hm-calendar-range-middle-background", "--hm-table-row-selected-background", "--hm-pagination-hover-background", "--hm-command-highlight-background"])
assert(variable("--hm-table-row-selected-background").includes("#ffd36d 20%"), "Table receives Accent 20% independently")
applyRuntimeTheme(after, { schemaVersion: 1, tokens: { "color.action.cancel": "#345678" } })
assert(variable("--hm-button-cancel-runtime-ink") === "#345678", "Custom public cancel role retains Button mapping")
resetRuntimeTheme(after)
assert(after.style.getPropertyValue("--hm-button-cancel-runtime-ink") === "", "Cancel mapping cleaned on Reset")
applyRuntimeTheme(after, external)
for (const key of ["--hm-select-option-state-background", "--hm-calendar-range-middle-background", "--hm-pagination-hover-background", "--hm-command-highlight-background"])
assert(variable(key).includes("#ffd36d 45%"), `${key} receives Accent 45%`)
// Exhaustively inject every public entry, independently, through the same DOM scope.
for (const [key,def] of Object.entries(runtimeThemeContract)) {
@@ -179,11 +196,11 @@ async function verifyRuntime(canvasElement: HTMLElement) {
applyRuntimeTheme(after, external); resetRuntimeTheme(after)
assert(after.style.getPropertyValue("--hm-sem-state-active") === "#112233" && after.style.getPropertyPriority("--hm-sem-state-active") === "important", "Cleanup restores previous inline value and priority")
after.style.removeProperty("--hm-sem-state-active")
result.textContent = `PASS — ${checks.length} checks; ${initial.length} rendered parts; default parity, all public tokens, independent roles, partial/empty/invalid, switching, cleanup, global/scoped, geometry.`
result.textContent = `PASS — ${checks.length} checks; ${initial.length} rendered parts; default parity, all public tokens, independent roles, partial/empty/invalid, switching, cleanup, global/scoped, geometry. KEYBOARD_FOCUS: ${keyboardFocus ? "PASS" : "NOT VERIFIED"}.`
} catch (error) {
result.textContent = `FAIL after ${checks.length} checks — ${String(error)}`
throw error
} finally { resetRuntimeTheme(after); result.dataset.running = "false" }
} finally { resetRuntimeTheme(after); before.inert = previousInert[0]; after.inert = previousInert[1]; result.dataset.running = "false" }
}
export const Dashboard: Story = { render: () => <DashboardLayout
+14
View File
@@ -0,0 +1,14 @@
// Verification-only: compare settled CSS targets even when an inactive tab's
// animation clock stops. Editor/production transitions keep their normal timing.
export function VerificationStyles() {
return <style>{'[data-hm-theme-verification] *, [data-hm-theme-verification] *::before, [data-hm-theme-verification] *::after { transition-duration: 0s !important; transition-delay: 0s !important; }'}</style>
}
export async function waitForThemeState(ready: () => boolean, label: string) {
const deadline = performance.now() + 5000
while (!ready()) {
if (performance.now() >= deadline) throw Error(`Theme state did not settle: ${label}`)
// Retry a concrete condition; elapsed time alone never makes a check pass.
await new Promise<void>(resolve => setTimeout(resolve, 25))
}
}