Files
HMDesign/packages/ui/src/theme/runtime.ts
T

62 lines
3.1 KiB
TypeScript

import { runtimeThemeContract, runtimeGradientGroups, type RuntimeThemeToken } from "./contract"
import { invalidateThemeRequest } from "./requests"
type SavedProperty = { property: string; value: string; priority: string }
const applied = new WeakMap<HTMLElement, { properties: SavedProperty[]; scope: string | null }>()
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
/** Restore the host's previous inline values and scope. */
export function resetRuntimeTheme(root: HTMLElement) {
invalidateThemeRequest(root)
const previous = applied.get(root)
if (!previous) return
for (const { property, value, priority } of previous.properties) {
if (value) root.style.setProperty(property, value, priority)
else root.style.removeProperty(property)
}
if (previous.scope === null) root.removeAttribute("data-hm-theme")
else root.setAttribute("data-hm-theme", previous.scope)
applied.delete(root)
}
/** Validate without touching the DOM (editors/loaders can preserve their last valid value). */
export function isRuntimeTheme(theme: unknown): theme is import("./contract").RuntimeTheme {
if (!isRecord(theme) || theme.schemaVersion !== 1 || !isRecord(theme.tokens)) return false
const tokens = theme.tokens
for (const group of runtimeGradientGroups) {
const count = group.filter(key => Object.hasOwn(tokens, key)).length
if (count !== 0 && count !== group.length) return false
}
for (const [key, value] of Object.entries(tokens)) {
if (!Object.hasOwn(runtimeThemeContract, key) || typeof value !== "string") return false
const definition = runtimeThemeContract[key as RuntimeThemeToken]
const valid = definition.type === "radius"
? /^(?:[2-9]|1[0-2])px$/.test(value)
: /^#[0-9a-fA-F]{6}$/.test(value)
if (!valid) return false
}
return true
}
/** Atomic validation. Invalid documents release the previous override to host/default. */
export function applyRuntimeTheme(root: HTMLElement, theme: unknown): boolean {
if (!isRuntimeTheme(theme)) { resetRuntimeTheme(root); return false }
const values: (readonly [string, string])[] = Object.entries(theme.tokens).map(([key, value]) =>
[runtimeThemeContract[key as RuntimeThemeToken].property, value!] as const)
// Reviewed Gray default comes from the existing palette. Retain custom cancel
// overrides without changing the public token or the full Default preset.
const cancel = theme.tokens["color.action.cancel"]
if (cancel && cancel.toLowerCase() !== runtimeThemeContract["color.action.cancel"].defaultValue)
values.push(["--hm-button-cancel-runtime-ink", cancel])
resetRuntimeTheme(root)
const scope = root.getAttribute("data-hm-theme")
const properties = values.map(([property]) => ({
property, value: root.style.getPropertyValue(property), priority: root.style.getPropertyPriority(property),
}))
root.setAttribute("data-hm-theme", "runtime")
for (const [property, value] of values) root.style.setProperty(property, value)
applied.set(root, { properties, scope })
return true
}