Your Name 3101f5966d
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: enhance canvas agent UI customization
2026-04-23 13:25:05 +08:00

309 lines
8.5 KiB
TypeScript

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<string, string>;
typography?: Record<
string,
{
fontFamily?: string;
fontSize?: number;
fontWeight?: number | string;
lineHeight?: number;
}
>;
spacing?: Record<string, number>;
radii?: Record<string, number>;
shadows?: Record<string, string>;
}
export interface UiScreenDocument {
version: "ui-screen/v1";
id: string;
title?: string;
width: number;
height: number;
tokens?: UiScreenTokens;
assets?: UiScreenAsset[];
layers: UiScreenLayer[];
metadata?: Record<string, unknown>;
}
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<string, unknown>;
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<string, unknown>;
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<string, unknown>)[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<string, unknown>)[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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>)
: undefined,
};
}