ai-chatbot/lib/detail-ui-surface-adapter.ts
Your Name 54f02be0f5
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: refine detail catalog component layouts
2026-03-22 00:28:58 +08:00

1132 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
DetailGenerationMode,
DetailUiGlobalStyle,
DetailUiProtocol,
DetailUiScreenInput,
DetailUiScreenProtocol,
DetailUiScreenReviewPatch,
} from "@/lib/detail-ui-protocol";
import type {
UiSurfaceComponent,
UiSurfacePatch,
UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
function createDetailSurfaceId(params: {
generationMode: DetailGenerationMode;
screenCount: number;
}) {
return `detail-surface-${params.generationMode}-${params.screenCount}-790x1300`;
}
function createRootComponent(): UiSurfaceComponent {
return {
id: "detail-page-root",
type: "detail_page_root",
parentId: null,
props: {
role: "detail_page",
},
};
}
type DetailSurfaceLayoutVariant = "hero_left" | "hero_right" | "split_bottom";
function splitBodyCopyIntoHighlights(copy?: string) {
const segments = String(copy || "")
.split(/[。\n!!??;;]+/g)
.map((segment) => segment.replace(/\s+/g, " ").trim())
.filter(Boolean);
if (segments.length === 0) return [];
if (segments.length <= 3) return segments;
return [
segments.slice(0, 2).join(" · "),
segments.slice(2, 4).join(" · "),
segments.slice(4).join(" · "),
].filter(Boolean);
}
function pickScreenLayoutVariant(params: {
screenIndex: number;
screenType?: string;
}): DetailSurfaceLayoutVariant {
if (params.screenIndex === 1 || params.screenType === "B") {
return "hero_left";
}
if (params.screenType === "A") {
return "hero_right";
}
return "split_bottom";
}
function buildScreenBadgeLabel(params: {
screenIndex: number;
screenType?: string;
objective?: string;
}) {
const normalizedObjective = String(params.objective || "").replace(/\s+/g, " ").trim();
if (normalizedObjective) return normalizedObjective.slice(0, 18);
if (params.screenType === "A") return "技术证明";
if (params.screenType === "B") return "场景共鸣";
if (params.screenType === "C") return "细节证明";
return `第 ${params.screenIndex} 屏`;
}
function createStyledSurfaceTextComponent(params: {
id: string;
type:
| "detail_card"
| "detail_badge"
| "detail_text_block"
| "detail_feature_card"
| "detail_feature_tag"
| "detail_spec_table"
| "detail_table_label"
| "detail_table_value"
| "detail_comparison_strip"
| "detail_compare_before"
| "detail_compare_after"
| "detail_proof_bar"
| "detail_cta_banner"
| "detail_cta_button"
| "detail_cta_support";
parentId: string;
frame: { x: number; y: number; width: number; height: number };
zIndex?: number;
content?: string;
bindingPath?: string;
role: string;
style: Record<string, unknown>;
metadata?: Record<string, unknown>;
}) {
return {
id: params.id,
type: params.type,
parentId: params.parentId,
props: {
role: params.role,
content: params.content || "",
},
bindings: params.bindingPath
? {
content: {
kind: "text" as const,
path: params.bindingPath,
},
}
: undefined,
layout: {
frame: params.frame,
zIndex: params.zIndex || 1,
},
style: params.style,
metadata: params.metadata,
} satisfies UiSurfaceComponent;
}
function buildCatalogStrings(params: {
screenIndex: number;
totalScreens?: number;
screen: Pick<
DetailUiScreenInput,
"title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
}) {
const highlights = splitBodyCopyIntoHighlights(params.screen.copy).slice(0, 3);
const primaryTitle = String(params.screen.title || "").replace(/\s+/g, " ").trim();
const subtitle = String(params.screen.subtitle || "").replace(/\s+/g, " ").trim();
const objective = String(params.screen.objective || "").replace(/\s+/g, " ").trim();
const layoutNote = String(params.screen.layoutNote || "").replace(/\s+/g, " ").trim();
const catalogTitle = primaryTitle || "核心卖点";
const featureCards = (highlights.length > 0
? highlights
: [subtitle || catalogTitle].filter(Boolean)
)
.slice(0, 3)
.map((item, index) => ({
tag:
index === 0
? "核心升级"
: index === 1
? "场景收益"
: "舒适证明",
title: item,
detail:
index === 0
? subtitle || objective || "围绕核心体验完成升级"
: index === 1
? objective || layoutNote || "匹配真实使用场景"
: layoutNote || subtitle || "用可感知结果证明价值",
accent: index === 0 ? "primary" : index === 1 ? "secondary" : "neutral",
}));
const specRows = [
{ label: "核心卖点", value: catalogTitle || "舒适支撑" },
...(objective ? [{ label: "适用场景", value: objective }] : []),
...(subtitle ? [{ label: "结果表达", value: subtitle }] : []),
...(layoutNote ? [{ label: "设计要求", value: layoutNote }] : []),
];
const comparison = {
beforeTitle: "旧方式",
beforeBody: highlights[0] || "久坐容易疲劳、支撑不足、使用场景不聚焦",
afterTitle: "现在",
afterBody: highlights[1] || subtitle || "围绕护腰/舒适/效率完成体验升级",
};
const proof = {
label: params.screen.screenType === "A" ? "技术证明" : "结果证明",
value: layoutNote || objective || subtitle || catalogTitle,
};
const shouldShowCta =
params.screenIndex === (params.totalScreens || 0) ||
/下单|购买|收口|转化|cta|call to action/i.test(layoutNote);
const cta = shouldShowCta
? {
buttonLabel: params.screen.screenType === "C" ? "立即对比体验" : "立即了解详情",
supportText: subtitle || objective || "继续查看完整卖点与场景说明",
highlightText: catalogTitle,
}
: null;
return {
featureCards,
specRows,
comparison,
proof,
cta,
};
}
function createSectionComponent(params: {
id: string;
parentId: string;
role: string;
frame: { x: number; y: number; width: number; height: number };
zIndex?: number;
metadata?: Record<string, unknown>;
}) {
return {
id: params.id,
type: "detail_section" as const,
parentId: params.parentId,
props: {
role: params.role,
},
layout: {
frame: params.frame,
zIndex: params.zIndex || 1,
},
metadata: params.metadata,
} satisfies UiSurfaceComponent;
}
function buildSectionedTextComponents(params: {
screenId: string;
textStackId: string;
screenIndex: number;
totalScreens?: number;
screenSize: { width: number; height: number };
screen: Pick<
DetailUiScreenInput,
"title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
screenProtocol?: DetailUiScreenProtocol;
}) {
const safeArea = params.screenProtocol?.safeArea || {
top: 64,
bottom: 360,
left: 56,
right: 56,
};
const contentWidth = Math.max(
260,
Math.round(params.screenSize.width - safeArea.left - safeArea.right),
);
const variant = pickScreenLayoutVariant({
screenIndex: params.screenIndex,
screenType: params.screen.screenType,
});
const badgeLabel = buildScreenBadgeLabel({
screenIndex: params.screenIndex,
screenType: params.screen.screenType,
objective: params.screen.objective,
});
const catalog = buildCatalogStrings({
screenIndex: params.screenIndex,
totalScreens: params.totalScreens,
screen: params.screen,
});
const titleBlock =
params.screenProtocol?.blocks.find((block) => block.type === "title") || null;
const subtitleBlock =
params.screenProtocol?.blocks.find((block) => block.type === "subtitle") || null;
const bodyBlock =
params.screenProtocol?.blocks.find((block) => block.type === "body") || null;
const cardX =
variant === "hero_right"
? Math.round(params.screenSize.width - safeArea.right - Math.min(430, contentWidth))
: safeArea.left;
const cardWidth = Math.min(430, contentWidth);
const titleY = safeArea.top + 42;
const subtitleY = titleY + Math.max(124, Math.round(Number(titleBlock?.frame.height || 132))) + 14;
const components: UiSurfaceComponent[] = [];
components.push(
createSectionComponent({
id: `${params.screenId}-section-hero`,
parentId: params.textStackId,
role: "hero_section",
frame: {
x: cardX,
y: safeArea.top,
width: cardWidth,
height: Math.min(360, params.screenSize.height - safeArea.top - 120),
},
metadata: {
screenIndex: params.screenIndex,
variant,
},
}),
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-badge`,
type: "detail_badge",
parentId: `${params.screenId}-section-hero`,
frame: {
x: cardX,
y: safeArea.top,
width: Math.min(156, Math.max(104, badgeLabel.length * 18)),
height: 34,
},
zIndex: 3,
content: badgeLabel,
role: "screen_badge",
style: {
fontSize: 16,
fontWeight: "700",
color: "#f8fafc",
textAlign: "center",
lineHeight: 1.15,
letterSpacing: 0.4,
backgroundColor: "rgba(15,23,42,0.82)",
borderColor: "rgba(255,255,255,0.18)",
borderWidth: 1,
borderRadius: 999,
padding: 8,
boxShadow: "0 10px 28px rgba(15,23,42,0.20)",
backdropBlur: 8,
textTransform: "uppercase",
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "badge",
},
}),
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-title-card`,
type: "detail_card",
parentId: `${params.screenId}-section-hero`,
frame: {
x: cardX,
y: titleY,
width: cardWidth,
height: Math.max(132, Math.round(Number(titleBlock?.frame.height || 132) + 34)),
},
zIndex: 2,
bindingPath: `/screens/${params.screenIndex - 1}/title`,
role: "title_card",
style: {
fontFamily: titleBlock?.style.fontFamily || undefined,
fontSize: Math.max(34, Number(titleBlock?.style.fontSize || 52)),
fontWeight: titleBlock?.style.fontWeight || "800",
color: "#0f172a",
textAlign: "left",
lineHeight: 1.08,
letterSpacing: -0.8,
backgroundColor: "rgba(255,255,255,0.86)",
borderColor: "rgba(255,255,255,0.38)",
borderWidth: 1,
borderRadius: 28,
padding: 24,
boxShadow: "0 22px 58px rgba(15,23,42,0.18)",
backdropBlur: 14,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "title",
},
}),
);
if (params.screen.subtitle) {
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-subtitle-card`,
type: "detail_card",
parentId: `${params.screenId}-section-hero`,
frame: {
x: cardX,
y: subtitleY,
width: Math.min(cardWidth, 360),
height: Math.max(72, Math.round(Number(subtitleBlock?.frame.height || 64) + 26)),
},
zIndex: 2,
bindingPath: `/screens/${params.screenIndex - 1}/subtitle`,
role: "subtitle_card",
style: {
fontFamily: subtitleBlock?.style.fontFamily || undefined,
fontSize: Math.max(22, Number(subtitleBlock?.style.fontSize || 28)),
fontWeight: subtitleBlock?.style.fontWeight || "700",
color: "#f8fafc",
textAlign: "left",
lineHeight: 1.18,
backgroundColor:
variant === "hero_right" ? "rgba(37,99,235,0.82)" : "rgba(30,41,59,0.78)",
borderColor: "rgba(255,255,255,0.18)",
borderWidth: 1,
borderRadius: 22,
padding: 18,
boxShadow: "0 18px 42px rgba(15,23,42,0.18)",
backdropBlur: 12,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "subtitle",
},
}),
);
}
const supportingSectionY =
variant === "split_bottom" ? Math.round(params.screenSize.height * 0.72) : subtitleY + 96;
const supportingSectionHeight =
variant === "split_bottom"
? Math.min(320, params.screenSize.height - supportingSectionY - 80)
: 240;
components.push(
createSectionComponent({
id: `${params.screenId}-section-supporting`,
parentId: params.textStackId,
role: variant === "split_bottom" ? "supporting_bottom_section" : "supporting_grid",
frame: {
x: safeArea.left,
y: supportingSectionY,
width: contentWidth,
height: supportingSectionHeight,
},
zIndex: 1,
metadata: {
screenIndex: params.screenIndex,
variant,
},
}),
);
const supportingCards =
catalog.featureCards.length > 0
? catalog.featureCards
: [
{
tag: "核心信息",
title: String(params.screen.copy || "").trim() || catalog.proof.value,
detail: catalog.proof.value,
accent: "primary",
},
];
const cardGap = 14;
const supportColumns = variant === "split_bottom" ? Math.min(3, supportingCards.length) : Math.min(2, Math.max(1, supportingCards.length));
const supportCardWidth = Math.floor(
(contentWidth - cardGap * Math.max(0, supportColumns - 1)) / supportColumns,
);
supportingCards.forEach((highlight, index) => {
const column = supportColumns === 0 ? 0 : index % supportColumns;
const row = supportColumns === 0 ? 0 : Math.floor(index / supportColumns);
const x = safeArea.left + column * (supportCardWidth + cardGap);
const y = supportingSectionY + row * 116;
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-feature-tag-${index + 1}`,
type: "detail_feature_tag",
parentId: `${params.screenId}-section-supporting`,
frame: {
x: x + 16,
y: y + 8,
width: Math.min(112, Math.max(76, highlight.tag.length * 17)),
height: 26,
},
zIndex: 3,
content: `✦ ${highlight.tag}`,
role: "feature_tag",
style: {
fontSize: 13,
fontWeight: "700",
color:
highlight.accent === "primary"
? "#1d4ed8"
: highlight.accent === "secondary"
? "#7c3aed"
: "#334155",
textAlign: "center",
lineHeight: 1.1,
backgroundColor:
highlight.accent === "primary"
? "rgba(219,234,254,0.96)"
: highlight.accent === "secondary"
? "rgba(237,233,254,0.96)"
: "rgba(241,245,249,0.96)",
borderColor:
highlight.accent === "primary"
? "rgba(59,130,246,0.24)"
: highlight.accent === "secondary"
? "rgba(124,58,237,0.24)"
: "rgba(148,163,184,0.24)",
borderWidth: 1,
borderRadius: 999,
padding: 6,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "feature_tag",
},
}),
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-support-card-${index + 1}`,
type: "detail_feature_card",
parentId: `${params.screenId}-section-supporting`,
frame: {
x,
y: y + 20,
width: supportCardWidth,
height: variant === "split_bottom" ? 116 : 110,
},
zIndex: 2,
content: `${index + 1}. ${highlight.title}${highlight.detail ? `\n${highlight.detail}` : ""}`,
role: "support_card",
style: {
fontSize: bodyBlock ? Math.max(18, Number(bodyBlock.style.fontSize || 22)) : 20,
fontWeight: "600",
color: "#111827",
textAlign: "left",
lineHeight: 1.34,
backgroundColor:
variant === "split_bottom"
? "rgba(255,255,255,0.92)"
: "rgba(248,250,252,0.82)",
borderColor:
highlight.accent === "primary"
? "rgba(59,130,246,0.42)"
: highlight.accent === "secondary"
? "rgba(124,58,237,0.34)"
: "rgba(148,163,184,0.28)",
borderWidth: 1,
borderRadius: 24,
padding: 20,
boxShadow: "0 18px 42px rgba(15,23,42,0.12)",
backdropBlur: 12,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "support_card",
},
}),
);
});
if (catalog.specRows.length > 0 && params.screen.screenType === "A") {
const tableX = safeArea.left;
const tableY =
variant === "split_bottom"
? supportingSectionY - 140
: supportingSectionY + supportingSectionHeight - 220;
const tableWidth = Math.min(contentWidth, 396);
const labelWidth = 106;
const rowGap = 10;
components.push(
createSectionComponent({
id: `${params.screenId}-section-spec-table`,
parentId: `${params.screenId}-section-supporting`,
role: "spec_table_section",
frame: {
x: tableX,
y: tableY,
width: tableWidth,
height: 196,
},
zIndex: 2,
metadata: {
screenIndex: params.screenIndex,
componentRole: "spec_table_section",
},
}),
);
catalog.specRows.slice(0, 4).forEach((row, index) => {
const rowY = tableY + index * (38 + rowGap);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-table-label-${index + 1}`,
type: "detail_table_label",
parentId: `${params.screenId}-section-spec-table`,
frame: {
x: tableX,
y: rowY,
width: labelWidth,
height: 38,
},
zIndex: 3,
content: row.label,
role: "table_label",
style: {
fontSize: 14,
fontWeight: "700",
color: "#334155",
textAlign: "center",
lineHeight: 1.15,
backgroundColor: "rgba(241,245,249,0.96)",
borderColor: "rgba(148,163,184,0.22)",
borderWidth: 1,
borderRadius: 14,
padding: 10,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "table_label",
},
}),
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-table-value-${index + 1}`,
type: "detail_table_value",
parentId: `${params.screenId}-section-spec-table`,
frame: {
x: tableX + labelWidth + 10,
y: rowY,
width: tableWidth - labelWidth - 10,
height: 38,
},
zIndex: 3,
content: row.value,
role: "table_value",
style: {
fontSize: 15,
fontWeight: "600",
color: "#0f172a",
textAlign: "left",
lineHeight: 1.18,
backgroundColor: "rgba(255,255,255,0.94)",
borderColor: "rgba(59,130,246,0.16)",
borderWidth: 1,
borderRadius: 14,
padding: 12,
boxShadow: "0 8px 22px rgba(15,23,42,0.08)",
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "table_value",
},
}),
);
});
}
if ((catalog.comparison.beforeBody || catalog.comparison.afterBody) && params.screen.screenType === "C") {
const compareY = supportingSectionY - 126;
const compareWidth = Math.min(contentWidth, 430);
const compareColumnWidth = Math.floor((compareWidth - 12) / 2);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-compare-before`,
type: "detail_compare_before",
parentId: `${params.screenId}-section-supporting`,
frame: {
x: safeArea.left,
y: compareY,
width: compareColumnWidth,
height: 170,
},
zIndex: 2,
content: `${catalog.comparison.beforeTitle}\n${catalog.comparison.beforeBody}`,
role: "comparison_before",
style: {
fontSize: 17,
fontWeight: "700",
color: "#334155",
textAlign: "left",
lineHeight: 1.4,
backgroundColor: "rgba(255,255,255,0.92)",
borderColor: "rgba(148,163,184,0.20)",
borderWidth: 1,
borderRadius: 26,
padding: 20,
boxShadow: "0 18px 44px rgba(15,23,42,0.10)",
backdropBlur: 10,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "comparison_before",
},
}),
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-compare-after`,
type: "detail_compare_after",
parentId: `${params.screenId}-section-supporting`,
frame: {
x: safeArea.left + compareColumnWidth + 12,
y: compareY,
width: compareColumnWidth,
height: 170,
},
zIndex: 2,
content: `${catalog.comparison.afterTitle}\n${catalog.comparison.afterBody}`,
role: "comparison_after",
style: {
fontSize: 17,
fontWeight: "800",
color: "#eff6ff",
textAlign: "left",
lineHeight: 1.4,
backgroundColor: "rgba(37,99,235,0.88)",
borderColor: "rgba(255,255,255,0.18)",
borderWidth: 1,
borderRadius: 26,
padding: 20,
boxShadow: "0 20px 48px rgba(37,99,235,0.22)",
backdropBlur: 12,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "comparison_after",
},
}),
);
}
if (catalog.proof.value) {
const noteY = Math.min(
params.screenSize.height - 116,
supportingSectionY + supportingSectionHeight - 74,
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-proof-bar`,
type: "detail_proof_bar",
parentId: `${params.screenId}-section-supporting`,
frame: {
x:
variant === "hero_right"
? Math.round(params.screenSize.width - safeArea.right - Math.min(260, contentWidth * 0.46))
: safeArea.left,
y: noteY,
width: Math.min(260, contentWidth * 0.46),
height: 62,
},
zIndex: 2,
content: `${catalog.proof.label}|${catalog.proof.value}`,
role: "proof_bar",
style: {
fontSize: 16,
fontWeight: "600",
color: "#1e293b",
textAlign: "left",
lineHeight: 1.22,
backgroundColor: "rgba(255,255,255,0.88)",
borderColor: "rgba(15,23,42,0.10)",
borderWidth: 1,
borderRadius: 18,
padding: 14,
boxShadow: "0 14px 36px rgba(15,23,42,0.12)",
backdropBlur: 10,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "proof_bar",
},
}),
);
}
if (catalog.cta) {
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-cta-support`,
type: "detail_cta_support",
parentId: params.textStackId,
frame: {
x: safeArea.left,
y: Math.min(params.screenSize.height - 198, supportingSectionY + supportingSectionHeight + 4),
width: Math.min(contentWidth, 430),
height: 70,
},
zIndex: 2,
content: catalog.cta.highlightText,
role: "cta_support",
style: {
fontSize: 18,
fontWeight: "800",
color: "#0f172a",
textAlign: "left",
lineHeight: 1.2,
backgroundColor: "rgba(255,255,255,0.88)",
borderColor: "rgba(37,99,235,0.14)",
borderWidth: 1,
borderRadius: 24,
padding: 18,
boxShadow: "0 14px 40px rgba(15,23,42,0.10)",
backdropBlur: 10,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "cta_support",
},
}),
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-cta-button`,
type: "detail_cta_button",
parentId: params.textStackId,
frame: {
x: safeArea.left,
y: Math.min(params.screenSize.height - 118, supportingSectionY + supportingSectionHeight + 76),
width: Math.min(268, contentWidth * 0.62),
height: 58,
},
zIndex: 3,
content: `${catalog.cta.buttonLabel} →`,
role: "cta_button",
style: {
fontSize: 19,
fontWeight: "800",
color: "#f8fafc",
textAlign: "center",
lineHeight: 1.05,
letterSpacing: 0.2,
backgroundColor: "rgba(37,99,235,0.94)",
borderColor: "rgba(255,255,255,0.24)",
borderWidth: 1,
borderRadius: 999,
padding: 18,
boxShadow: "0 22px 58px rgba(37,99,235,0.28)",
backdropBlur: 12,
textTransform: "uppercase",
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "cta_button",
},
}),
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-cta-banner`,
type: "detail_cta_banner",
parentId: params.textStackId,
frame: {
x: safeArea.left + Math.min(268, contentWidth * 0.62) + 12,
y: Math.min(params.screenSize.height - 118, supportingSectionY + supportingSectionHeight + 76),
width: Math.max(140, Math.min(430, contentWidth) - Math.min(268, contentWidth * 0.62) - 12),
height: 58,
},
zIndex: 2,
content: catalog.cta.supportText,
role: "cta_banner",
style: {
fontSize: 15,
fontWeight: "600",
color: "#eff6ff",
textAlign: "left",
lineHeight: 1.22,
backgroundColor: "rgba(15,23,42,0.74)",
borderColor: "rgba(255,255,255,0.16)",
borderWidth: 1,
borderRadius: 22,
padding: 16,
boxShadow: "0 18px 42px rgba(15,23,42,0.18)",
backdropBlur: 10,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "cta_banner",
},
}),
);
}
return components;
}
function createScreenSubtree(params: {
screenIndex: number;
totalScreens?: number;
screen: Pick<
DetailUiScreenInput,
"title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
screenSize: { width: number; height: number };
screenProtocol?: DetailUiScreenProtocol;
rootId?: string;
}) {
const screenId = params.rootId || `detail-screen-${params.screenIndex}`;
const backgroundId = `${screenId}-background`;
const textStackId = `${screenId}-text-stack`;
const components: UiSurfaceComponent[] = [
{
id: screenId,
type: "detail_screen",
parentId: "detail-page-root",
props: {
screenIndex: params.screenIndex,
screenType: params.screen.screenType || "",
objective: params.screen.objective || "",
layoutNote: params.screen.layoutNote || "",
},
metadata: {
screenIndex: params.screenIndex,
},
},
{
id: backgroundId,
type: "detail_background_image",
parentId: screenId,
slot: "background",
props: {
screenIndex: params.screenIndex,
},
metadata: {
screenIndex: params.screenIndex,
},
},
{
id: textStackId,
type: "detail_text_stack",
parentId: screenId,
slot: "content",
props: {
screenIndex: params.screenIndex,
safeArea: params.screenProtocol?.safeArea || null,
},
metadata: {
screenIndex: params.screenIndex,
},
},
];
components.push(
...buildSectionedTextComponents({
screenId,
textStackId,
screenIndex: params.screenIndex,
totalScreens: params.totalScreens,
screenSize: params.screenSize,
screen: params.screen,
screenProtocol: params.screenProtocol,
}),
);
return components;
}
function buildDetailSurfaceDataModel(params: {
summary?: string;
screens: DetailUiScreenInput[];
globalStyle?: DetailUiGlobalStyle;
}) {
return {
summary: params.summary || "",
globalStyle: params.globalStyle || {},
screens: params.screens.map((screen) => ({
index: screen.index,
title: String(screen.title || "").trim(),
subtitle: String(screen.subtitle || "").trim(),
copy: String(screen.copy || "").trim(),
screenType: screen.screenType || "",
objective: screen.objective || "",
layoutNote: screen.layoutNote || "",
})),
};
}
export function buildDetailUiSurfaceSnapshot(params: {
surfaceId?: string;
generationMode: DetailGenerationMode;
screenSize: { width: number; height: number };
summary?: string;
globalStyle?: DetailUiGlobalStyle;
screens: DetailUiScreenInput[];
uiProtocol?: DetailUiProtocol;
uiValidation?: {
passed: boolean;
issues: string[];
};
}) {
const surfaceId =
params.surfaceId ||
createDetailSurfaceId({
generationMode: params.generationMode,
screenCount: params.screens.length,
});
const components: UiSurfaceComponent[] = [createRootComponent()];
params.screens.forEach((screen) => {
const screenProtocol = params.uiProtocol?.screens.find(
(item) => item.index === screen.index,
);
components.push(
...createScreenSubtree({
screenIndex: screen.index,
totalScreens: params.screens.length,
screen,
screenSize: params.screenSize,
screenProtocol,
}),
);
});
return {
version: "ui-surface/v1",
surfaceId,
surfaceType: "detail_page",
generationMode: params.generationMode,
rootComponentId: "detail-page-root",
screenSize: params.screenSize,
components,
dataModel: buildDetailSurfaceDataModel({
summary: params.summary,
screens: params.screens,
globalStyle: params.globalStyle,
}),
validation: params.uiValidation
? {
passed: params.uiValidation.passed,
issues: params.uiValidation.issues,
source: "plan",
}
: undefined,
metadata: {
detailScreenCount: params.screens.length,
},
} satisfies UiSurfaceSnapshot;
}
export function buildDetailUiSurfacePatchFromReview(params: {
surfaceId: string;
screenIndex: number;
screen: Pick<
DetailUiScreenInput,
"index" | "title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
uiPatch?: DetailUiScreenReviewPatch;
uiValidation?: {
passed: boolean;
issues: string[];
};
}) {
if (!params.uiPatch) return null;
const screenId = `detail-screen-${params.screenIndex}`;
const textStackId = `${screenId}-text-stack`;
const replacementComponents = createScreenSubtree({
screenIndex: params.screenIndex,
totalScreens: params.screenIndex,
screen: params.screen,
screenSize: {
width: 790,
height: 1300,
},
screenProtocol: params.uiPatch.recommendedScreen,
rootId: screenId,
}).filter(
(component) =>
component.id !== screenId &&
component.id !== `${screenId}-background`,
);
return {
version: "ui-surface-patch/v1",
surfaceId: params.surfaceId,
surfaceType: "detail_page",
operations: [
{
type: "replace_component_subtree",
componentId: textStackId,
components: replacementComponents,
},
{
type: "set_data",
updates: [
{
path: `/screens/${params.screenIndex - 1}/title`,
value: String(params.screen.title || "").trim(),
},
{
path: `/screens/${params.screenIndex - 1}/subtitle`,
value: String(params.screen.subtitle || "").trim(),
},
{
path: `/screens/${params.screenIndex - 1}/copy`,
value: String(params.screen.copy || "").trim(),
},
{
path: `/screens/${params.screenIndex - 1}/layoutNote`,
value: String(params.screen.layoutNote || "").trim(),
},
],
},
...(params.uiValidation
? [
{
type: "set_validation" as const,
validation: {
passed: params.uiValidation.passed,
issues: params.uiValidation.issues,
source: "review" as const,
},
},
]
: []),
],
} satisfies UiSurfacePatch;
}
export function findDetailSurfaceScreenComponent(
snapshot: UiSurfaceSnapshot | null | undefined,
screenIndex: number,
) {
if (!snapshot) return null;
return (
snapshot.components.find(
(component) =>
component.type === "detail_screen" &&
Number(component.props?.screenIndex || 0) === screenIndex,
) || null
);
}