export type DetailGenerationMode = "classic" | "agent_layout"; export type DetailUiTextBlockType = "title" | "subtitle" | "body"; export type DetailUiTextAlign = "left" | "center" | "right"; export interface DetailUiGlobalStyle { designLanguage?: string; typography?: { fontFamily?: string; title?: string; subtitle?: string; body?: string; }; layout?: { grid?: string; safeArea?: string; rhythm?: string; }; palette?: string; } export interface DetailUiScreenInput { index: number; title?: string; subtitle?: string; copy?: string; screenType?: "A" | "B" | "C"; objective?: string; layoutNote?: string; } export interface DetailUiFrame { x: number; y: number; width: number; height: number; } export interface DetailUiTextStyle { fontFamily?: string; fontSize: number; fontWeight: string; lineHeight: number; color: string; textAlign: DetailUiTextAlign; } export interface DetailUiTextBlock { id: string; type: DetailUiTextBlockType; content: string; frame: DetailUiFrame; style: DetailUiTextStyle; } export interface DetailUiScreenProtocol { index: number; screenType?: "A" | "B" | "C"; objective?: string; layoutNote?: string; safeArea: { top: number; bottom: number; left: number; right: number; }; blocks: DetailUiTextBlock[]; } export interface DetailUiProtocol { version: "detail-ui-protocol/v1"; mode: DetailGenerationMode; screenSize: { width: number; height: number; }; globalStyle?: DetailUiGlobalStyle; screens: DetailUiScreenProtocol[]; } export interface DetailUiPatchNote { type: "layout" | "typography" | "conversion"; message: string; } export interface DetailUiScreenReviewPatch { version: "detail-ui-review-patch/v1"; mode: DetailGenerationMode; screenIndex: number; recommendedScreen: DetailUiScreenProtocol; notes: DetailUiPatchNote[]; } function extractFirstNumber(value: string, fallback: number): number { const matched = String(value || "").match(/(\d+(?:\.\d+)?)/); return matched ? Number(matched[1]) : fallback; } function extractFontWeight(value: string, fallback = "600"): string { const matched = String(value || "").match(/\b(100|200|300|400|500|600|700|800|900)\b/); return matched?.[1] || fallback; } function parseSafeArea(input?: string, width = 790, height = 1300) { const raw = String(input || ""); const yMatch = raw.match(/y\s*=\s*(\d+)\s*[-~~]\s*(\d+)/i); const leftMatch = raw.match(/(?:左右边距|左边距|左侧边距)\s*(\d+)\s*px/i); const rightMatch = raw.match(/(?:左右边距|右边距|右侧边距)\s*(\d+)\s*px/i); const top = yMatch ? Number(yMatch[1]) : 64; const bottom = yMatch ? Number(yMatch[2]) : Math.min(height - 120, 360); const left = leftMatch ? Number(leftMatch[1]) : 56; const right = rightMatch ? Number(rightMatch[1]) : left; return { top, bottom, left: Math.max(24, Math.min(width / 3, left)), right: Math.max(24, Math.min(width / 3, right)), }; } function buildTextBlockStyle( type: DetailUiTextBlockType, globalStyle?: DetailUiGlobalStyle, screenIndex = 0, ): DetailUiTextStyle { const typography = globalStyle?.typography; const titleBase = extractFirstNumber(typography?.title || "", 64); const subtitleBase = extractFirstNumber(typography?.subtitle || "", 34); const bodyBase = extractFirstNumber(typography?.body || "", 24); if (type === "title") { const fontSize = Math.max(36, Math.min(80, screenIndex === 0 ? titleBase : Math.round(titleBase * 0.86))); return { fontFamily: typography?.fontFamily, fontSize, fontWeight: extractFontWeight(typography?.title || "", "700"), lineHeight: 1.15, color: "#111827", textAlign: "left", }; } if (type === "subtitle") { return { fontFamily: typography?.fontFamily, fontSize: Math.max(24, Math.min(50, subtitleBase)), fontWeight: extractFontWeight(typography?.subtitle || "", "600"), lineHeight: 1.22, color: "#374151", textAlign: "left", }; } return { fontFamily: typography?.fontFamily, fontSize: Math.max(18, Math.min(36, bodyBase)), fontWeight: extractFontWeight(typography?.body || "", "500"), lineHeight: 1.38, color: "#4b5563", textAlign: "left", }; } function estimateBlockHeight( content: string, style: DetailUiTextStyle, width: number, maxLines: number, ) { const safeWidth = Math.max(120, width); const charsPerLine = Math.max(8, Math.floor(safeWidth / Math.max(10, style.fontSize * 0.9))); const estimatedLines = Math.min( maxLines, Math.max(1, Math.ceil(Array.from(String(content || "")).length / charsPerLine)), ); return Math.ceil(style.fontSize * style.lineHeight * estimatedLines + 16); } export function buildDetailUiProtocol(params: { generationMode: DetailGenerationMode; screenSize: { width: number; height: number }; globalStyle?: DetailUiGlobalStyle; screens: DetailUiScreenInput[]; }): DetailUiProtocol { const { generationMode, screenSize, globalStyle } = params; const screens = params.screens.map((screen, index) => { const safeArea = parseSafeArea(globalStyle?.layout?.safeArea, screenSize.width, screenSize.height); const textWidth = Math.max(240, Math.round(screenSize.width - safeArea.left - safeArea.right)); const blocks: DetailUiTextBlock[] = []; const titleText = String(screen.title || "").trim(); const subtitleText = String(screen.subtitle || "").trim(); const bodyText = String(screen.copy || "").trim(); let cursorY = safeArea.top; if (titleText) { const style = buildTextBlockStyle("title", globalStyle, index); const height = estimateBlockHeight(titleText, style, textWidth, 2); blocks.push({ id: `screen-${screen.index}-title`, type: "title", content: titleText, frame: { x: safeArea.left, y: cursorY, width: textWidth, height, }, style, }); cursorY += height + 16; } if (subtitleText) { const style = buildTextBlockStyle("subtitle", globalStyle, index); const height = estimateBlockHeight(subtitleText, style, textWidth, 3); blocks.push({ id: `screen-${screen.index}-subtitle`, type: "subtitle", content: subtitleText, frame: { x: safeArea.left, y: cursorY, width: textWidth, height, }, style, }); cursorY += height + 14; } if (bodyText) { const style = buildTextBlockStyle("body", globalStyle, index); const remainingHeight = Math.max(96, safeArea.bottom - cursorY); const estimatedHeight = estimateBlockHeight(bodyText, style, textWidth, 5); blocks.push({ id: `screen-${screen.index}-body`, type: "body", content: bodyText, frame: { x: safeArea.left, y: cursorY, width: textWidth, height: Math.min(remainingHeight, estimatedHeight), }, style, }); } return { index: screen.index, screenType: screen.screenType, objective: screen.objective, layoutNote: screen.layoutNote, safeArea, blocks, } satisfies DetailUiScreenProtocol; }); return { version: "detail-ui-protocol/v1", mode: generationMode, screenSize, globalStyle, screens, }; } export function buildDetailUiReviewPatch(params: { generationMode: DetailGenerationMode; screenSize: { width: number; height: number }; globalStyle?: DetailUiGlobalStyle; screen: DetailUiScreenInput; layoutFixes?: string[]; typographyFixes?: string[]; conversionFixes?: string[]; }): DetailUiScreenReviewPatch { const protocol = buildDetailUiProtocol({ generationMode: params.generationMode, screenSize: params.screenSize, globalStyle: params.globalStyle, screens: [params.screen], }); return { version: "detail-ui-review-patch/v1", mode: params.generationMode, screenIndex: params.screen.index, recommendedScreen: protocol.screens[0]!, notes: [ ...(params.layoutFixes || []).map((message) => ({ type: "layout" as const, message })), ...(params.typographyFixes || []).map((message) => ({ type: "typography" as const, message })), ...(params.conversionFixes || []).map((message) => ({ type: "conversion" as const, message })), ], }; }