Initial commit: HMDesignSystem baseline
This commit is contained in:
commit
b6a0a61103
486 files changed
+68432
No files matched your search
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Button, Combobox, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
|
||||
ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem,
|
||||
Menubar, MenubarMenu, MenubarTrigger, MenubarContent, MenubarItem,
|
||||
DataTable, useDataTable, dataTableSelectColumn, type DataTableColumnDef,
|
||||
} from "@hm-design/ui"
|
||||
|
||||
const data = [{ id: "one", name: "선택 행 / Active + Accent" }, { id: "two", name: "일반 행 / Hover" }]
|
||||
const columns: DataTableColumnDef<(typeof data)[number]>[] = [dataTableSelectColumn(), { accessorKey: "name", header: "Data Table" }]
|
||||
const options = [{ value: "one", label: "Selected option" }, { value: "two", label: "Highlighted option" }]
|
||||
|
||||
export function DesignerExtras() {
|
||||
const table = useDataTable({ columns, data, getRowId: row => row.id, initialState: { rowSelection: { one: true } } })
|
||||
return <div style={{ display: "grid", gap: 18, padding: 20 }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
|
||||
<Combobox options={options} defaultValue="one" aria-label="Theme combobox" />
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button appearance="outline">Dropdown Preview</Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent><DropdownMenuItem>Dropdown highlight</DropdownMenuItem><DropdownMenuItem variant="destructive">Destructive action</DropdownMenuItem></DropdownMenuContent></DropdownMenu>
|
||||
<ContextMenu><ContextMenuTrigger style={{ border: "1px solid var(--hm-sem-stroke-default)", padding: 8 }}>우클릭 Context Preview</ContextMenuTrigger>
|
||||
<ContextMenuContent><ContextMenuItem>Context highlight</ContextMenuItem></ContextMenuContent></ContextMenu>
|
||||
<Menubar><MenubarMenu><MenubarTrigger>Menubar Preview</MenubarTrigger><MenubarContent><MenubarItem>Menubar highlight</MenubarItem></MenubarContent></MenubarMenu></Menubar>
|
||||
</div>
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useLayoutEffect, useSyncExternalStore, type ReactNode } from "react"
|
||||
import { applyRuntimeTheme, resetRuntimeTheme, resolveRuntimeTheme, isRuntimeTheme, runtimeThemeContract, type RuntimeTheme, type RuntimeThemeToken } from "@hm-design/ui/theme"
|
||||
import defaultJson from "../../themes/default.json"
|
||||
|
||||
// Storybook-only persistence; production owns its own JSON delivery/storage policy.
|
||||
const storageKey = "hm-designer-color-theme-v1"
|
||||
const listeners = new Set<() => void>()
|
||||
export function isDesignerTheme(value: unknown): value is RuntimeTheme {
|
||||
return isRuntimeTheme(value) && Object.keys(value.tokens).every(key => runtimeThemeContract[key as RuntimeThemeToken].type === "color")
|
||||
}
|
||||
let current: RuntimeTheme = { schemaVersion: 1, tokens: {} }
|
||||
try {
|
||||
const saved: unknown = JSON.parse(sessionStorage.getItem(storageKey) ?? "null")
|
||||
if (isDesignerTheme(saved)) current = saved
|
||||
} catch { /* Disabled storage or stale documents fall back to HM Default. */ }
|
||||
|
||||
export const designerThemeStore = {
|
||||
getSnapshot: () => current,
|
||||
subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener) } },
|
||||
set(theme: unknown): boolean {
|
||||
if (!isDesignerTheme(theme)) return false
|
||||
current = { schemaVersion: 1, tokens: { ...theme.tokens } }
|
||||
try { sessionStorage.setItem(storageKey, JSON.stringify(current)) } catch { /* Live editing works without storage. */ }
|
||||
listeners.forEach(listener => listener())
|
||||
return true
|
||||
},
|
||||
reset: () => designerThemeStore.set({ schemaVersion: 1, tokens: {} }),
|
||||
}
|
||||
|
||||
export function DesignerThemePreview({ children, enabled }: { children: ReactNode; enabled: boolean }) {
|
||||
const theme = useSyncExternalStore(designerThemeStore.subscribe, designerThemeStore.getSnapshot)
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return
|
||||
applyRuntimeTheme(document.documentElement, resolveRuntimeTheme(theme, defaultJson))
|
||||
return () => resetRuntimeTheme(document.documentElement)
|
||||
}, [theme, enabled])
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.hm-designer { color: var(--hm-sem-fg-primary); background: var(--hm-sem-bg-content); font-size: 14px; }
|
||||
.hm-designer h1 { font-size: 24px; font-weight: 700; margin: 4px 0 12px; }
|
||||
.hm-designer h2 { font-size: 18px; font-weight: 700; margin-bottom: 8px; }
|
||||
.hm-designer p, .hm-designer small { color: var(--hm-sem-fg-secondary); line-height: 1.6; }
|
||||
.hm-designer-header { display: flex; gap: 16px; justify-content: space-between; align-items: start; margin-bottom: 24px; }
|
||||
.hm-designer-layout { display: grid; grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); gap: 24px; }
|
||||
.hm-designer-editor, .hm-designer-preview { min-width: 0; padding: 20px; background: var(--hm-sem-bg-surface); border: 1px solid var(--hm-sem-stroke-input-default); border-radius: 4px; }
|
||||
.hm-designer-field { padding: 14px 0; border-bottom: 1px solid var(--hm-sem-stroke-input-default); }
|
||||
.hm-designer-field label { display: block; font-weight: 600; }
|
||||
.hm-designer-field small { display: block; font-size: 12px; font-weight: 400; margin: 3px 0 8px; }
|
||||
.hm-designer-inputs { display: flex; gap: 8px; }
|
||||
.hm-designer-inputs input[type=color] { width: 42px; height: 34px; padding: 2px; cursor: pointer; }
|
||||
.hm-designer-inputs input:not([type=color]) { width: 120px; height: 34px; padding: 6px 8px; font-family: monospace; }
|
||||
.hm-designer-editor input, .hm-designer-editor textarea { border: 1px solid var(--hm-sem-stroke-input-default); background: var(--hm-sem-bg-surface); color: var(--hm-sem-fg-primary); border-radius: 4px; }
|
||||
.hm-designer-editor input[aria-invalid=true] { border-color: var(--hm-sem-action-destructive); }
|
||||
.hm-designer-editor input:focus-visible, .hm-designer-editor textarea:focus-visible,
|
||||
.hm-designer-header button:focus-visible, .hm-designer-source button:focus-visible,
|
||||
.hm-designer-transfer button:focus-visible { outline: 2px solid var(--hm-sem-stroke-input-focus); outline-offset: 2px; }
|
||||
.hm-designer-header button, .hm-designer-transfer button, .hm-designer-source button { border: 1px solid var(--hm-sem-stroke-default); border-radius: 4px; background: var(--hm-sem-bg-surface); padding: 7px 10px; color: var(--hm-sem-fg-primary); cursor: pointer; font-size: 13px; }
|
||||
.hm-designer-source { padding-bottom: 20px; margin-bottom: 20px; border-bottom: 1px solid var(--hm-sem-stroke-input-default); }
|
||||
.hm-designer-source label { display: block; margin-bottom: 6px; }
|
||||
.hm-designer-source input { width: 100%; padding: 8px; margin-bottom: 8px; }
|
||||
.hm-designer button:disabled { opacity: 0.5; cursor: wait; }
|
||||
.hm-designer-transfer { margin-top: 24px; }
|
||||
.hm-designer-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }
|
||||
.hm-designer-transfer textarea { width: 100%; padding: 10px; font-family: monospace; font-size: 12px; margin-bottom: 10px; }
|
||||
.hm-designer-transfer input[type=file] { width: 100%; margin-top: 12px; }
|
||||
.hm-designer-transfer output { display: block; margin-top: 12px; overflow-wrap: anywhere; }
|
||||
.hm-designer-preview > p { margin-bottom: 12px; }
|
||||
@media (max-width: 900px) { .hm-designer-layout { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 480px) { .hm-designer-header { flex-direction: column; } .hm-designer-editor, .hm-designer-preview { padding: 12px; } }
|
||||
@@ -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() }
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Storybook compatibility adapter: Preview and service apps share one contract/API.
|
||||
export { applyRuntimeTheme as applyPreviewTheme, resetRuntimeTheme as resetPreviewTheme } from "@hm-design/ui/theme"
|
||||
@@ -0,0 +1,91 @@
|
||||
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"
|
||||
|
||||
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>
|
||||
<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>
|
||||
<div data-testid="loader-preview"><Specimens /><DesignerExtras /></div>
|
||||
</main>,
|
||||
play: async ({ canvasElement }) => verifyLoader(canvasElement),
|
||||
}
|
||||
|
||||
async function verifyLoader(container: HTMLElement) {
|
||||
const root = document.documentElement
|
||||
const result = container.querySelector<HTMLOutputElement>('[data-testid="loader-result"]')!
|
||||
if (result.dataset.running === "true") return
|
||||
result.dataset.running = "true"
|
||||
const checks: string[] = []
|
||||
const assert = (ok: boolean, label: string) => { if (!ok) throw Error(label); checks.push(label) }
|
||||
const variable = (property: string) => getComputedStyle(root).getPropertyValue(property).trim().toLowerCase()
|
||||
const geometry = () => Array.from(container.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 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()
|
||||
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")
|
||||
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")
|
||||
assert(await loadRuntimeTheme(root, "/themes/service-example.json", { defaults }), "Loader accepts separately fetched Default JSON")
|
||||
assert(variable("--hm-sem-action-primary") === "#6837a8", "Service override wins supplied Default")
|
||||
resetRuntimeTheme(root)
|
||||
assert(variable("--hm-sem-state-active") === "#266651", "Reset returns package Default immediately")
|
||||
const invalid = [null, {}, {schemaVersion:2,tokens:{}}, {schemaVersion:1,tokens:{"unknown":"#123456"}}, {schemaVersion:1,tokens:{"color.state.active":null}}, {schemaVersion:1,tokens:{"color.state.active":"var(--evil)"}}, {schemaVersion:1,tokens:{"color.state.active":"url(javascript:evil)"}}, {schemaVersion:1,tokens:{"color.state.active":"<script>evil</script>"}}, {schemaVersion:1,tokens:{"color.state.active":"#123456; color:red"}}, {schemaVersion:1,tokens:{"color.state.active":"#abc"}}, {schemaVersion:1,tokens:{"color.bg.brand.stop.0":"#123456"}}, {schemaVersion:1,tokens:{"--hm-checkbox-checked-background":"#123456"}}]
|
||||
for (const document of invalid) {
|
||||
applyRuntimeTheme(root, { schemaVersion:1,tokens:{"color.state.active":"#007f73"} })
|
||||
assert(!await loadRuntimeTheme(root, dataUrl(document)), "Invalid/schema/missing/unsafe input fails closed")
|
||||
assert(variable("--hm-sem-state-active") === "#266651", "Invalid URL JSON releases override to HM fallback")
|
||||
}
|
||||
for (const url of ["data:application/json,malformed", "/themes/missing-verification-theme.json"]) {
|
||||
applyRuntimeTheme(root, {schemaVersion:1,tokens:{"color.state.active":"#007f73"}})
|
||||
assert(!await loadRuntimeTheme(root, url) && variable("--hm-sem-state-active") === "#266651", "Malformed/HTTP failure keeps usable HM Default")
|
||||
}
|
||||
const controller = new AbortController(); controller.abort()
|
||||
assert(!await loadRuntimeTheme(root, "/themes/default.json", {request:{signal:controller.signal}}), "Aborted fetch falls back safely")
|
||||
assert(await loadRuntimeTheme(root, dataUrl({schemaVersion:1,tokens:{}})), "Empty JSON uses HM Default")
|
||||
assert(variable("--hm-sem-state-active") === "#266651", "Empty JSON leaves Default Active")
|
||||
// Deterministic delayed CDN response simulation; no production network monkeypatch.
|
||||
let finish: ((response: Response) => void) | undefined
|
||||
globalThis.fetch = (input, init) => String(input).includes("delayed-verification")
|
||||
? new Promise<Response>(resolve => { finish = resolve }) : originalFetch(input, init)
|
||||
const delayed = () => new Response(JSON.stringify({schemaVersion:1,tokens:{"color.state.active":"#111111"}}), {headers:{"Content-Type":"application/json"}})
|
||||
const old = loadRuntimeTheme(root, "/delayed-verification")
|
||||
await loadRuntimeTheme(root, "/themes/service-example.json")
|
||||
finish!(delayed())
|
||||
assert(!await old && variable("--hm-sem-state-active") === "#007f73", "Older delayed CDN response cannot replace newer Theme")
|
||||
const pendingReset = loadRuntimeTheme(root, "/delayed-verification")
|
||||
resetRuntimeTheme(root); finish!(delayed())
|
||||
assert(!await pendingReset && variable("--hm-sem-state-active") === "#266651", "Reset cancels pending load ownership")
|
||||
const pendingApply = loadRuntimeTheme(root, "/delayed-verification")
|
||||
applyRuntimeTheme(root, {schemaVersion:1,tokens:{"color.state.active":"#345678"}}); finish!(delayed())
|
||||
assert(!await pendingApply && variable("--hm-sem-state-active") === "#345678", "Direct Apply supersedes pending load")
|
||||
resetRuntimeTheme(root)
|
||||
assert(JSON.stringify(geometry()) === initial && performance.timeOrigin === origin, "All URL changes preserve geometry in same document; reload 0")
|
||||
result.textContent = `PASS — ${checks.length} loader checks; viewport ${innerWidth}; Default/External/partial/reset/invalid, HTTP simulation, abort/race, geometry, reload 0.`
|
||||
} catch(error) { result.textContent = `FAIL after ${checks.length} checks: ${String(error)}`; throw error }
|
||||
finally { globalThis.fetch = originalFetch; resetRuntimeTheme(root); result.dataset.running = "false" }
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useLayoutEffect, useRef, useSyncExternalStore, type CSSProperties } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { DashboardHeader, DashboardLayout, DashboardMain, DashboardSidebar } from "@hm-design/ui"
|
||||
import { Specimens } from "./specimens"
|
||||
import { applyRuntimeTheme, resetRuntimeTheme, defaultRuntimeTheme, runtimeThemeContract, type RuntimeTheme } from "@hm-design/ui/theme"
|
||||
import baseline from "./verification-baseline.json"
|
||||
|
||||
// Verification values are deliberately distinct. They are never imported by production.
|
||||
const external: RuntimeTheme = { schemaVersion: 1, tokens: {
|
||||
"color.action.primary": "#6837a8", "color.state.active": "#007f73",
|
||||
"color.stroke.input.focus": "#c75b00", "color.accent": "#ffd36d",
|
||||
"color.action.destructive": "#d41466",
|
||||
} }
|
||||
const meta = { title: "Theme/Runtime Contract", tags: ["verification"], parameters: { layout: "fullscreen" } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function ThemeScope({ theme, label, showNavigation = true }: { theme?: RuntimeTheme; label: string; showNavigation?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
const root = ref.current!
|
||||
if (theme) applyRuntimeTheme(root, theme)
|
||||
return () => resetRuntimeTheme(root)
|
||||
}, [theme])
|
||||
return <section ref={ref} data-hm-theme="preset" data-testid={label}>
|
||||
<h2 style={{ padding: "12px 20px" }}>{label}</h2><Specimens showNavigation={showNavigation} />
|
||||
</section>
|
||||
}
|
||||
|
||||
export const Comparison: Story = { render: () => <>
|
||||
<p style={{ padding: 20 }}>Default / External: Primary purple / Active teal / Focus orange / Accent yellow / Destructive pink.</p>
|
||||
<ThemeScope label="HM Default" /><ThemeScope label="External" theme={external} />
|
||||
</> }
|
||||
|
||||
function GlobalSpecimens() {
|
||||
useLayoutEffect(() => {
|
||||
applyRuntimeTheme(document.documentElement, external)
|
||||
return () => resetRuntimeTheme(document.documentElement)
|
||||
}, [])
|
||||
return <><h1 style={{ padding: 20 }}>Global External — popup/Portal verification</h1><Specimens /></>
|
||||
}
|
||||
export const GlobalExternal: Story = { render: () => <GlobalSpecimens /> }
|
||||
|
||||
const properties = ["backgroundColor", "backgroundImage", "color", "borderTopColor", "borderRadius", "boxShadow", "width", "height", "padding", "gap", "fontSize", "lineHeight", "display"] as const
|
||||
// CSS Color 4 serializes color-mix as color(srgb); canonicalize it to legacy RGB for before/after parity.
|
||||
const normalize = (value: string) => value.replace(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/g,
|
||||
(_, r, g, b, a) => a && Number(a) !== 1 ? `rgba(${Math.round(Number(r)*255)}, ${Math.round(Number(g)*255)}, ${Math.round(Number(b)*255)}, ${Number(a)})` : `rgb(${Math.round(Number(r)*255)}, ${Math.round(Number(g)*255)}, ${Math.round(Number(b)*255)})`)
|
||||
const snapshot = (root: HTMLElement) => Array.from(root.querySelectorAll<HTMLElement>("[data-slot]"))
|
||||
.map(el => { const s = getComputedStyle(el); return { slot: el.dataset.slot, values: properties.map(p => normalize(s[p])) } })
|
||||
const settle = async (root: HTMLElement) => {
|
||||
// Bound the wait even when the browser suspends a transition in an inactive Story tab.
|
||||
void root.offsetHeight
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
const shadowHasColor = (shadow: string, hex: string) => {
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true })!
|
||||
const target = [1, 3, 5].map(start => parseInt(hex.slice(start, start+2), 16))
|
||||
return [...shadow.matchAll(/(?:oklab|rgba?|color)\([^)]*\)/g)].some(([color]) => {
|
||||
context.clearRect(0,0,1,1); context.fillStyle = color; context.fillRect(0,0,1,1)
|
||||
const pixel = context.getImageData(0,0,1,1).data
|
||||
return pixel[3] > 0 && target.every((channel,i) => Math.abs(channel-pixel[i]) <= 2)
|
||||
})
|
||||
}
|
||||
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
|
||||
return <div data-testid="before" data-hm-theme="baseline" style={values as CSSProperties}><Specimens /></div>
|
||||
}
|
||||
|
||||
export const Verification: Story = {
|
||||
render: () => <div>
|
||||
{/* 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>
|
||||
<output data-testid="result" style={{ display: "block", padding: 20 }}>Ready — Run verification</output>
|
||||
<BaselineSpecimens />
|
||||
<div data-testid="after" data-hm-theme="preset"><Specimens /></div>
|
||||
</div>,
|
||||
play: async ({ canvasElement }) => verifyRuntime(canvasElement),
|
||||
}
|
||||
|
||||
async function verifyRuntime(canvasElement: HTMLElement) {
|
||||
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"
|
||||
const checks: string[] = []
|
||||
const assert = (condition: boolean, label: string) => { if (!condition) throw Error(label); checks.push(label) }
|
||||
const variable = (name: string) => {
|
||||
const value = getComputedStyle(after).getPropertyValue(name).trim().toLowerCase()
|
||||
// Static CSS minification can shorten HEX6 to HEX3; compare the same color.
|
||||
return /^#[\da-f]{3}$/.test(value) ? `#${value.slice(1).split("").map(character => character.repeat(2)).join("")}` : value
|
||||
}
|
||||
result.textContent = "Running… waiting for fonts"
|
||||
await document.fonts.ready
|
||||
result.textContent = "Running… comparing rendered Default"
|
||||
await settle(after)
|
||||
const initial = snapshot(after)
|
||||
const parity = () => snapshot(after).flatMap((row,i) => row.values.flatMap((v,j) => v === initial[i]?.values[j] ? [] : [`${row.slot}.${properties[j]}: ${initial[i]?.values[j]} → ${v}`]))
|
||||
try {
|
||||
const old = snapshot(before)
|
||||
const differences = initial.flatMap((row, i) => row.values.flatMap((v,j) => v === old[i]?.values[j] ? [] : [`${row.slot}.${properties[j]}: ${old[i]?.values[j]} → ${v}`]))
|
||||
assert(differences.length === 0, `Default visual parity (${initial.length} rendered parts) ${differences.join("; ")}`)
|
||||
assert(applyRuntimeTheme(after, defaultRuntimeTheme), "Default preset satisfies public contract")
|
||||
assert(JSON.stringify(snapshot(after)) === JSON.stringify(initial), "Default preset injection equals CSS fallback")
|
||||
resetRuntimeTheme(after)
|
||||
assert(applyRuntimeTheme(after, external), "External partial theme accepted")
|
||||
await settle(after)
|
||||
const expected: Record<string, string> = {
|
||||
"--hm-checkbox-checked-background": "#007f73", "--hm-radio-dot-color": "#007f73",
|
||||
"--hm-switch-on-background": "#007f73", "--hm-slider-range-background": "#007f73",
|
||||
"--hm-progress-indicator-background": "#007f73", "--hm-calendar-selected-background": "#007f73",
|
||||
"--hm-calendar-range-edge-background": "#007f73", "--hm-badge-default-background": "#6837a8",
|
||||
"--hm-pagination-active-background": "#6837a8", "--hm-input-focus-border-color": "#c75b00",
|
||||
"--ring": "#c75b00", "--hm-input-error-border-color": "#d41466",
|
||||
"--hm-button-outline-red-ink": "#d41466", "--hm-button-outline-green-ink": "#6837a8",
|
||||
"--hm-gnb-current-text": "#6837a8", "--hm-lnb-current-text": "#6837a8",
|
||||
}
|
||||
for (const [key,value] of Object.entries(expected)) assert(variable(key) === value, `${key} independent role (${variable(key)})`)
|
||||
const checked = after.querySelector<HTMLElement>('[data-slot="checkbox"][data-state="checked"]')!
|
||||
const badge = after.querySelector<HTMLElement>('[data-slot="badge"]')!
|
||||
const selectedDay = after.querySelector<HTMLElement>('[data-slot="calendar"] [data-selected] .rdp-day_button')!
|
||||
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()
|
||||
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(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)) {
|
||||
const group = key.includes("stop.") && !key.startsWith("color.palette.")
|
||||
if (group) continue // Covered as complete groups by the full default preset and external gradient document below.
|
||||
const value = def.type === "radius" ? "7px" : "#345678"
|
||||
assert(applyRuntimeTheme(after, { schemaVersion: 1, tokens: { [key]: value } }) && variable(def.property) === value, `${key} override`)
|
||||
}
|
||||
const stops = Object.fromEntries(Object.keys(runtimeThemeContract).filter(key => key.includes("stop.")).map(key => [key, "#345678"]))
|
||||
assert(applyRuntimeTheme(after, { schemaVersion: 1, tokens: stops }), "External complete gradient groups accepted")
|
||||
assert(variable("--hm-button-brand-green-background").includes("#345678"), "External stops reach Brand Button gradient")
|
||||
assert(variable("--hm-gnb-background").includes("#345678"), "External stops reach GNB gradient")
|
||||
// One-role changes may not leak into other core roles.
|
||||
const roles = ["color.action.primary", "color.state.active", "color.stroke.input.focus", "color.accent", "color.action.destructive"] as const
|
||||
for (const key of roles) {
|
||||
applyRuntimeTheme(after, { schemaVersion: 1, tokens: { [key]: "#345678" } })
|
||||
for (const other of roles.filter(k => k !== key)) assert(variable(runtimeThemeContract[other].property) === runtimeThemeContract[other].defaultValue, `${key} leaves ${other} unchanged`)
|
||||
}
|
||||
assert(applyRuntimeTheme(after, { schemaVersion: 1, tokens: {} }), "Empty tokens accepted")
|
||||
await settle(after)
|
||||
assert(parity().length === 0, `Empty theme preserves Default ${parity().join("; ")}`)
|
||||
for (const invalid of [null, {}, {schemaVersion:2,tokens:{}}, {schemaVersion:1,tokens:{"color.state.active":"red"}}, JSON.parse('{"schemaVersion":1,"tokens":{"__proto__":"#123456","color.state.active":"#007f73"}}'), {schemaVersion:1,tokens:{"--hm-checkbox-checked-background":"#123456"}}, {schemaVersion:1,tokens:{"color.bg.brand.stop.0":"#123456"}}]) {
|
||||
applyRuntimeTheme(after, external)
|
||||
assert(!applyRuntimeTheme(after, invalid), "Invalid document rejected atomically")
|
||||
await settle(after)
|
||||
assert(parity().length === 0, `Invalid document releases override to Default ${parity().join("; ")}`)
|
||||
}
|
||||
applyRuntimeTheme(after, external)
|
||||
const geometry = snapshot(after).map(r => r.values.slice(4,5).concat(r.values.slice(6)))
|
||||
assert(JSON.stringify(geometry) === JSON.stringify(initial.map(r => r.values.slice(4,5).concat(r.values.slice(6)))), "External color theme preserves geometry")
|
||||
// Global A + local B remain separate, and cleanup restores host-owned inline values.
|
||||
const globalRoot = document.documentElement
|
||||
try {
|
||||
applyRuntimeTheme(globalRoot, external)
|
||||
applyRuntimeTheme(after, {schemaVersion:1,tokens:{"color.state.active":"#345678"}})
|
||||
assert(variable("--hm-checkbox-checked-background") === "#345678", "Scoped B overrides global A")
|
||||
assert(getComputedStyle(globalRoot).getPropertyValue("--hm-checkbox-checked-background").trim() === "#007f73", "Global A retains its own mapping")
|
||||
assert(variable("--hm-badge-default-background") === "#123328", "Explicit isolated scope falls back to HM preset")
|
||||
} finally { resetRuntimeTheme(globalRoot); resetRuntimeTheme(after) }
|
||||
after.style.setProperty("--hm-sem-state-active", "#112233", "important")
|
||||
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.`
|
||||
} catch (error) {
|
||||
result.textContent = `FAIL after ${checks.length} checks — ${String(error)}`
|
||||
throw error
|
||||
} finally { resetRuntimeTheme(after); result.dataset.running = "false" }
|
||||
}
|
||||
|
||||
export const Dashboard: Story = { render: () => <DashboardLayout
|
||||
header={<DashboardHeader><strong>Theme Dashboard</strong></DashboardHeader>}
|
||||
sidebar={<DashboardSidebar><nav aria-label="Dashboard 메뉴"><a href="#" aria-current="page">Current</a></nav></DashboardSidebar>}>
|
||||
<DashboardMain><ThemeScope label="External" theme={external} showNavigation={false} /></DashboardMain>
|
||||
</DashboardLayout> }
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Button, Input, Checkbox, RadioGroup, RadioGroupItem, Switch, Select, SelectTrigger, SelectValue,
|
||||
SelectContent, SelectItem, Slider, Progress, Calendar, DatePicker, Pagination, PaginationContent,
|
||||
PaginationItem, PaginationLink, Badge, Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
|
||||
GNB, GNBBrand, GNBNav, GNBMobileMenu, GNBMobileLink, GNBUtilities, LNB, LNBBrand, LNBNav, LNBItem,
|
||||
LNBMobileHeader, LNBMobileBrand, LNBMobileMenu, LNBMobileLink,
|
||||
Command, CommandList, CommandItem,
|
||||
} from "@hm-design/ui"
|
||||
export function Specimens({ navigationMode = "GNB", showNavigation = true }: { navigationMode?: "GNB" | "LNB"; showNavigation?: boolean }) {
|
||||
return <div style={{ display: "grid", gap: 18, padding: 20 }}>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
|
||||
<Button appearance="outline">Primary</Button><Button>Brand gradient</Button>
|
||||
<Button variant="destructive" appearance="outline">Destructive</Button>
|
||||
<Badge>Badge</Badge><Badge variant="destructive">Error</Badge>
|
||||
<Checkbox defaultChecked aria-label="Checked" /><Checkbox aria-label="Focus checkbox" />
|
||||
<RadioGroup defaultValue="yes"><RadioGroupItem value="yes" aria-label="Selected radio" /></RadioGroup>
|
||||
<Switch defaultChecked aria-label="ON" />
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 12 }}>
|
||||
<Input placeholder="Focus input" /><Input aria-invalid="true" placeholder="Invalid input" />
|
||||
<Select defaultValue="a"><SelectTrigger aria-label="Select"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="a">Selected</SelectItem><SelectItem value="b">Highlighted</SelectItem></SelectContent>
|
||||
</Select>
|
||||
<DatePicker defaultValue={new Date(2026, 9, 15)} />
|
||||
<Slider defaultValue={[60]} aria-label="Active slider" /><Progress value={60} />
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 20 }}>
|
||||
<Calendar mode="single" defaultMonth={new Date(2026, 9, 1)} today={new Date(2026, 9, 2)} selected={new Date(2026, 9, 15)} />
|
||||
<Calendar mode="range" defaultMonth={new Date(2026, 9, 1)} today={new Date(2026, 9, 2)} selected={{ from: new Date(2026, 9, 8), to: new Date(2026, 9, 14) }} />
|
||||
</div>
|
||||
<Pagination><PaginationContent><PaginationItem><PaginationLink href="#" isActive>1</PaginationLink></PaginationItem>
|
||||
<PaginationItem><PaginationLink href="#">2</PaginationLink></PaginationItem></PaginationContent></Pagination>
|
||||
<Table><TableHeader><TableRow><TableHead>Status</TableHead><TableHead>Value</TableHead></TableRow></TableHeader>
|
||||
<TableBody><TableRow data-state="selected"><TableCell>Selected</TableCell><TableCell>60</TableCell></TableRow>
|
||||
<TableRow><TableCell>Hover</TableCell><TableCell>40</TableCell></TableRow></TableBody></Table>
|
||||
<Command><CommandList><CommandItem value="a">Menu highlight</CommandItem><CommandItem value="b">Menu second</CommandItem></CommandList></Command>
|
||||
{showNavigation && navigationMode === "GNB" ? <GNB><GNBMobileMenu><GNBMobileLink href="#" active>Current</GNBMobileLink></GNBMobileMenu>
|
||||
<GNBBrand href="#"><span><strong>Runtime Theme</strong></span></GNBBrand>
|
||||
<GNBNav><span>GNB</span></GNBNav><GNBUtilities><Badge>Primary</Badge></GNBUtilities></GNB> : null}
|
||||
{showNavigation && navigationMode === "LNB" ? <div>
|
||||
<LNB style={{ minHeight: 180 }}><LNBBrand><strong>LNB Standalone</strong></LNBBrand>
|
||||
<LNBNav aria-label="LNB Preview"><LNBItem href="#" active>Current</LNBItem><LNBItem href="#">Item</LNBItem></LNBNav></LNB>
|
||||
<LNBMobileHeader>
|
||||
<LNBMobileMenu title="LNB Preview"><LNBMobileLink href="#" active>Current</LNBMobileLink><LNBMobileLink href="#">Item</LNBMobileLink></LNBMobileMenu>
|
||||
<LNBMobileBrand href="#">LNB Standalone</LNBMobileBrand>
|
||||
</LNBMobileHeader>
|
||||
</div> : null}
|
||||
</div>
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user