Initial commit: HMDesignSystem baseline

This commit is contained in:
shkim4 committed 2026-10-08 16:58:09 +09:00
commit b6a0a61103
486 files changed
+68432

No files matched your search

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