export type UiScreenLayerKind = | "frame" | "stack" | "grid" | "card" | "image" | "text" | "button" | "badge" | "divider"; export type UiScreenFit = "cover" | "contain" | "fill"; export type UiScreenDirection = "row" | "column"; export type UiScreenAlign = "start" | "center" | "end" | "stretch"; export type UiScreenJustify = "start" | "center" | "end" | "between"; export interface UiScreenBounds { x: number; y: number; width: number; height: number; } export interface UiScreenAsset { id: string; type: "image"; src: string; alt?: string; width?: number; height?: number; } export interface UiScreenLayerStyle { background?: string; color?: string; borderColor?: string; borderWidth?: number; borderRadius?: number; boxShadow?: string; blur?: number; padding?: number; paddingX?: number; paddingY?: number; gap?: number; fontFamily?: string; fontSize?: number; fontWeight?: number | string; lineHeight?: number; letterSpacing?: number; textAlign?: "left" | "center" | "right"; textTransform?: "none" | "uppercase" | "lowercase" | "capitalize"; } export interface UiScreenLayer { id: string; kind: UiScreenLayerKind; name?: string; visible?: boolean; opacity?: number; bounds?: UiScreenBounds; style?: UiScreenLayerStyle; children?: UiScreenLayer[]; text?: string; label?: string; assetId?: string; src?: string; alt?: string; fit?: UiScreenFit; direction?: UiScreenDirection; align?: UiScreenAlign; justify?: UiScreenJustify; columns?: number; tone?: "primary" | "secondary" | "neutral" | "success" | "danger"; } export interface UiScreenTokens { colors?: Record; typography?: Record< string, { fontFamily?: string; fontSize?: number; fontWeight?: number | string; lineHeight?: number; } >; spacing?: Record; radii?: Record; shadows?: Record; } export interface UiScreenDocument { version: "ui-screen/v1"; id: string; title?: string; width: number; height: number; tokens?: UiScreenTokens; assets?: UiScreenAsset[]; layers: UiScreenLayer[]; metadata?: Record; } function normalizeString(value: unknown, fallback = "") { return String(value || "").trim() || fallback; } function normalizeNumber(value: unknown, fallback: number, min = -Infinity, max = Infinity) { const parsed = Number(value); if (!Number.isFinite(parsed)) return fallback; return Math.max(min, Math.min(max, parsed)); } function normalizeBounds(value: unknown): UiScreenBounds | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const raw = value as Record; const bounds = { x: normalizeNumber(raw.x, 0), y: normalizeNumber(raw.y, 0), width: normalizeNumber(raw.width, 0, 0), height: normalizeNumber(raw.height, 0, 0), }; return bounds.width > 0 && bounds.height > 0 ? bounds : undefined; } function normalizeStyle(value: unknown): UiScreenLayerStyle | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const raw = value as Record; const style: UiScreenLayerStyle = {}; const stringKeys = [ "background", "color", "borderColor", "boxShadow", "fontFamily", "fontWeight", "textAlign", "textTransform", ] as const; stringKeys.forEach((key) => { const normalized = normalizeString(raw[key]); if (normalized) { (style as Record)[key] = normalized; } }); const numberKeys = [ "borderWidth", "borderRadius", "blur", "padding", "paddingX", "paddingY", "gap", "fontSize", "lineHeight", "letterSpacing", ] as const; numberKeys.forEach((key) => { if (raw[key] === undefined || raw[key] === null) return; (style as Record)[key] = normalizeNumber(raw[key], 0); }); if ( style.textAlign !== undefined && style.textAlign !== "left" && style.textAlign !== "center" && style.textAlign !== "right" ) { delete style.textAlign; } if ( style.textTransform !== undefined && style.textTransform !== "none" && style.textTransform !== "uppercase" && style.textTransform !== "lowercase" && style.textTransform !== "capitalize" ) { delete style.textTransform; } return Object.keys(style).length > 0 ? style : undefined; } function normalizeLayer(value: unknown): UiScreenLayer | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const raw = value as Record; const id = normalizeString(raw.id); const kind = normalizeString(raw.kind) as UiScreenLayerKind; if (!id) return null; if ( kind !== "frame" && kind !== "stack" && kind !== "grid" && kind !== "card" && kind !== "image" && kind !== "text" && kind !== "button" && kind !== "badge" && kind !== "divider" ) { return null; } const children = Array.isArray(raw.children) ? raw.children.map(normalizeLayer).filter(Boolean) : undefined; const layer: UiScreenLayer = { id, kind, name: normalizeString(raw.name) || undefined, visible: raw.visible === false ? false : undefined, opacity: raw.opacity === undefined || raw.opacity === null ? undefined : normalizeNumber(raw.opacity, 1, 0, 1), bounds: normalizeBounds(raw.bounds), style: normalizeStyle(raw.style), children: children && children.length > 0 ? (children as UiScreenLayer[]) : undefined, text: normalizeString(raw.text) || undefined, label: normalizeString(raw.label) || undefined, assetId: normalizeString(raw.assetId) || undefined, src: normalizeString(raw.src) || undefined, alt: normalizeString(raw.alt) || undefined, fit: raw.fit === "contain" || raw.fit === "fill" || raw.fit === "cover" ? raw.fit : undefined, direction: raw.direction === "row" ? "row" : raw.direction === "column" ? "column" : undefined, align: raw.align === "center" || raw.align === "end" || raw.align === "stretch" ? raw.align : raw.align === "start" ? "start" : undefined, justify: raw.justify === "center" || raw.justify === "end" || raw.justify === "between" || raw.justify === "start" ? raw.justify : undefined, columns: raw.columns === undefined ? undefined : Math.max(1, Math.round(normalizeNumber(raw.columns, 1))), tone: raw.tone === "secondary" || raw.tone === "neutral" || raw.tone === "success" || raw.tone === "danger" ? raw.tone : raw.tone === "primary" ? "primary" : undefined, }; return layer; } export function normalizeUiScreenDocument(value: unknown): UiScreenDocument | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const raw = value as Record; const layers = Array.isArray(raw.layers) ? raw.layers.map(normalizeLayer).filter(Boolean) : []; if (layers.length === 0) return null; return { version: "ui-screen/v1", id: normalizeString(raw.id, "ui-screen"), title: normalizeString(raw.title) || undefined, width: normalizeNumber(raw.width, 790, 240, 4096), height: normalizeNumber(raw.height, 1300, 240, 4096), tokens: raw.tokens && typeof raw.tokens === "object" && !Array.isArray(raw.tokens) ? (raw.tokens as UiScreenTokens) : undefined, assets: Array.isArray(raw.assets) ? raw.assets .map((asset) => { if (!asset || typeof asset !== "object" || Array.isArray(asset)) return null; const next = asset as Record; const id = normalizeString(next.id); const src = normalizeString(next.src); if (!id || !src) return null; return { id, type: "image" as const, src, alt: normalizeString(next.alt) || undefined, width: next.width === undefined ? undefined : normalizeNumber(next.width, 0, 0), height: next.height === undefined ? undefined : normalizeNumber(next.height, 0, 0), }; }) .filter(Boolean) as UiScreenAsset[] : undefined, layers: layers as UiScreenLayer[], metadata: raw.metadata && typeof raw.metadata === "object" && !Array.isArray(raw.metadata) ? (raw.metadata as Record) : undefined, }; }