diff --git a/components/canvas/DetailSurfaceInspectorPanel.tsx b/components/canvas/DetailSurfaceInspectorPanel.tsx
index f37b221b9..563139514 100644
--- a/components/canvas/DetailSurfaceInspectorPanel.tsx
+++ b/components/canvas/DetailSurfaceInspectorPanel.tsx
@@ -32,15 +32,42 @@ function getActionClassName(action?: DetailSurfaceInspectorReviewState["action"]
return "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300";
}
+function collectRenderableScreenComponents(surface: UiSurfaceSnapshot, parentId: string) {
+ const result = [] as typeof surface.components;
+ const queue = [...getUiSurfaceChildren(surface, parentId)];
+
+ while (queue.length > 0) {
+ const component = queue.shift();
+ if (!component) continue;
+
+ if (
+ component.type === "detail_text_block" ||
+ component.type === "detail_card" ||
+ component.type === "detail_badge"
+ ) {
+ result.push(component);
+ continue;
+ }
+
+ queue.push(...getUiSurfaceChildren(surface, component.id));
+ }
+
+ return result;
+}
+
export function DetailSurfaceInspectorPanel(params: {
surface: UiSurfaceSnapshot | null;
latestScreenValidation?: DetailSurfaceInspectorReviewState | null;
}) {
if (!params.surface) return null;
+ const surface = params.surface;
- const screenComponents = getUiSurfaceScreenComponents(params.surface);
- const totalComponents = countUiSurfaceComponents(params.surface);
- const totalTextBlocks = countUiSurfaceComponents(params.surface, "detail_text_block");
+ const screenComponents = getUiSurfaceScreenComponents(surface);
+ const totalRenderableBlocks =
+ countUiSurfaceComponents(surface, "detail_text_block") +
+ countUiSurfaceComponents(surface, "detail_card") +
+ countUiSurfaceComponents(surface, "detail_badge");
+ const totalSections = countUiSurfaceComponents(surface, "detail_section");
const totalScreens = screenComponents.length;
return (
@@ -51,12 +78,12 @@ export function DetailSurfaceInspectorPanel(params: {
Detail Surface Inspector
- {params.surface.surfaceId}
+ {surface.surfaceId}
- {params.surface.generationMode === "agent_layout" ? "AGENT Surface" : "Classic Surface"}
+ {surface.generationMode === "agent_layout" ? "AGENT Surface" : "Classic Surface"}
@@ -71,16 +98,16 @@ export function DetailSurfaceInspectorPanel(params: {
- Components
+ Sections
-
{totalComponents}
+
{totalSections}
- Text Blocks
+ UI Blocks
-
{totalTextBlocks}
+
{totalRenderableBlocks}
@@ -88,38 +115,44 @@ export function DetailSurfaceInspectorPanel(params: {
Surface Validation
- {params.surface.validation?.passed === false
- ? params.surface.validation.issues[0] || "存在待优化项"
+ {surface.validation?.passed === false
+ ? surface.validation.issues[0] || "存在待优化项"
: "当前 surface 已通过基础校验"}
- {params.surface.validation?.passed === false ? "待优化" : "通过"}
+ {surface.validation?.passed === false ? "待优化" : "通过"}
{screenComponents.slice(0, 6).map((screenComponent) => {
const screenIndex = Number(screenComponent.props?.screenIndex || 0);
- const textStack = getUiSurfaceChildren(params.surface, screenComponent.id).find(
+ const textStack = getUiSurfaceChildren(surface, screenComponent.id).find(
(component) => component.type === "detail_text_stack",
);
const textBlocks = textStack
- ? getUiSurfaceChildren(params.surface, textStack.id).filter(
- (component) => component.type === "detail_text_block",
- )
+ ? collectRenderableScreenComponents(surface, textStack.id)
: [];
const titleBlock =
- textBlocks.find((component) => component.props?.blockType === "title") || null;
+ textBlocks.find(
+ (component) =>
+ component.metadata?.componentRole === "title" ||
+ component.props?.blockType === "title",
+ ) || null;
const subtitleBlock =
- textBlocks.find((component) => component.props?.blockType === "subtitle") || null;
+ textBlocks.find(
+ (component) =>
+ component.metadata?.componentRole === "subtitle" ||
+ component.props?.blockType === "subtitle",
+ ) || null;
const isLatestScreen = params.latestScreenValidation?.screenIndex === screenIndex;
return (
@@ -139,11 +172,11 @@ export function DetailSurfaceInspectorPanel(params: {
: ""}
- {resolveUiSurfaceTextBinding(params.surface, titleBlock, "content") || "未定义标题"}
+ {resolveUiSurfaceTextBinding(surface, titleBlock, "content") || "未定义标题"}
{subtitleBlock ? (
- {resolveUiSurfaceTextBinding(params.surface, subtitleBlock, "content")}
+ {resolveUiSurfaceTextBinding(surface, subtitleBlock, "content")}
) : null}
diff --git a/components/canvas/nodes/TextNode.tsx b/components/canvas/nodes/TextNode.tsx
index bea54dbfc..73eeeb7c2 100644
--- a/components/canvas/nodes/TextNode.tsx
+++ b/components/canvas/nodes/TextNode.tsx
@@ -46,6 +46,41 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
setIsEditing(true);
};
+ const containerStyle = data.transparent
+ ? undefined
+ : {
+ backgroundColor: data.backgroundColor || undefined,
+ borderColor: data.borderColor || undefined,
+ borderWidth:
+ typeof data.borderWidth === "number" ? `${data.borderWidth}px` : undefined,
+ borderStyle:
+ typeof data.borderWidth === "number" && data.borderWidth > 0 ? "solid" : undefined,
+ borderRadius:
+ typeof data.borderRadius === "number" ? `${data.borderRadius}px` : undefined,
+ padding: typeof data.padding === "number" ? `${data.padding}px` : undefined,
+ boxShadow: data.boxShadow || undefined,
+ backdropFilter:
+ typeof data.backdropBlur === "number" && data.backdropBlur > 0
+ ? `blur(${data.backdropBlur}px)`
+ : undefined,
+ WebkitBackdropFilter:
+ typeof data.backdropBlur === "number" && data.backdropBlur > 0
+ ? `blur(${data.backdropBlur}px)`
+ : undefined,
+ };
+
+ const textStyle = {
+ fontSize: data.fontSize || 14,
+ fontFamily: data.fontFamily || undefined,
+ color: data.color || "inherit",
+ fontWeight: data.fontWeight || "normal",
+ lineHeight: data.lineHeight || 1.35,
+ letterSpacing:
+ typeof data.letterSpacing === "number" ? `${data.letterSpacing}px` : undefined,
+ textAlign: data.textAlign || "left",
+ textTransform: data.textTransform || "none",
+ } as const;
+
return (
{isEditing ? (
) : (
{data.content || '双击编辑文本'}
diff --git a/lib/detail-ui-renderer.ts b/lib/detail-ui-renderer.ts
index 75733e94b..ab9b6d465 100644
--- a/lib/detail-ui-renderer.ts
+++ b/lib/detail-ui-renderer.ts
@@ -29,10 +29,21 @@ function createTextNodeDraft(params: {
height: number;
content: string;
fontSize: number;
+ fontFamily?: string;
fontWeight: string;
color: string;
lineHeight: number;
+ letterSpacing?: number;
textAlign: "left" | "center" | "right";
+ transparent?: boolean;
+ backgroundColor?: string;
+ borderColor?: string;
+ borderWidth?: number;
+ borderRadius?: number;
+ padding?: number;
+ boxShadow?: string;
+ backdropBlur?: number;
+ textTransform?: "none" | "uppercase" | "lowercase" | "capitalize";
metadata?: Record
;
}): DetailTextLayerDraft {
return {
@@ -42,11 +53,21 @@ function createTextNodeDraft(params: {
data: {
content: params.content,
fontSize: params.fontSize,
+ fontFamily: params.fontFamily,
fontWeight: params.fontWeight,
color: params.color,
lineHeight: params.lineHeight,
+ letterSpacing: params.letterSpacing,
textAlign: params.textAlign,
- transparent: true,
+ transparent: params.transparent ?? true,
+ backgroundColor: params.backgroundColor,
+ borderColor: params.borderColor,
+ borderWidth: params.borderWidth,
+ borderRadius: params.borderRadius,
+ padding: params.padding,
+ boxShadow: params.boxShadow,
+ backdropBlur: params.backdropBlur,
+ textTransform: params.textTransform,
metadata: params.metadata,
} as TextNodeData & { metadata?: Record },
connections: [],
@@ -119,6 +140,43 @@ export function buildDetailTextLayerNodesFromReviewPatch(params: {
});
}
+function collectRenderableSurfaceComponents(params: {
+ surface: UiSurfaceSnapshot;
+ parentId: string;
+}) {
+ const collected: UiSurfaceSnapshot["components"] = [];
+ const queue = getUiSurfaceChildren(params.surface, params.parentId)
+ .sort((left, right) => {
+ const leftZ = Number(left.layout?.zIndex || 0);
+ const rightZ = Number(right.layout?.zIndex || 0);
+ return leftZ - rightZ;
+ });
+
+ while (queue.length > 0) {
+ const component = queue.shift();
+ if (!component) continue;
+
+ if (
+ component.type === "detail_card" ||
+ component.type === "detail_badge" ||
+ component.type === "detail_text_block"
+ ) {
+ collected.push(component);
+ continue;
+ }
+
+ const children = getUiSurfaceChildren(params.surface, component.id)
+ .sort((left, right) => {
+ const leftZ = Number(left.layout?.zIndex || 0);
+ const rightZ = Number(right.layout?.zIndex || 0);
+ return leftZ - rightZ;
+ });
+ queue.unshift(...children);
+ }
+
+ return collected;
+}
+
export function buildDetailTextLayerNodesFromSurfaceScreen(params: {
parentNodeId: string;
parentPosition: { x: number; y: number };
@@ -144,8 +202,10 @@ export function buildDetailTextLayerNodesFromSurfaceScreen(params: {
const scaleX = params.parentSize.width / Math.max(1, params.surface.screenSize.width);
const scaleY = params.parentSize.height / Math.max(1, params.surface.screenSize.height);
- return getUiSurfaceChildren(params.surface, textStack.id)
- .filter((component) => component.type === "detail_text_block")
+ return collectRenderableSurfaceComponents({
+ surface: params.surface,
+ parentId: textStack.id,
+ })
.map((component) => {
const frame = (component.layout?.frame || {}) as {
x?: number;
@@ -160,18 +220,42 @@ export function buildDetailTextLayerNodesFromSurfaceScreen(params: {
y: params.parentPosition.y + Math.round(Number(frame.y || 0) * scaleY),
width: Math.max(120, Math.round(Number(frame.width || 240) * scaleX)),
height: Math.max(48, Math.round(Number(frame.height || 60) * scaleY)),
- content: resolveUiSurfaceTextBinding(params.surface, component, "content"),
+ content:
+ resolveUiSurfaceTextBinding(params.surface, component, "content") ||
+ String(component.props?.content || "").trim(),
fontSize: Math.max(
12,
Math.round(Number(style.fontSize || 24) * Math.min(scaleX, scaleY)),
),
+ fontFamily: typeof style.fontFamily === "string" ? style.fontFamily : undefined,
fontWeight: String(style.fontWeight || "500"),
color: String(style.color || "#111827"),
lineHeight: Number(style.lineHeight || 1.3),
+ letterSpacing:
+ typeof style.letterSpacing === "number" ? style.letterSpacing : undefined,
textAlign:
style.textAlign === "center" || style.textAlign === "right"
? style.textAlign
: "left",
+ transparent: component.type === "detail_text_block" ? true : false,
+ backgroundColor:
+ typeof style.backgroundColor === "string" ? style.backgroundColor : undefined,
+ borderColor:
+ typeof style.borderColor === "string" ? style.borderColor : undefined,
+ borderWidth:
+ typeof style.borderWidth === "number" ? style.borderWidth : undefined,
+ borderRadius:
+ typeof style.borderRadius === "number" ? style.borderRadius : undefined,
+ padding: typeof style.padding === "number" ? style.padding : undefined,
+ boxShadow: typeof style.boxShadow === "string" ? style.boxShadow : undefined,
+ backdropBlur:
+ typeof style.backdropBlur === "number" ? style.backdropBlur : undefined,
+ textTransform:
+ style.textTransform === "uppercase" ||
+ style.textTransform === "lowercase" ||
+ style.textTransform === "capitalize"
+ ? style.textTransform
+ : "none",
metadata: {
detailUiManaged: true,
detailUiBlockId: String(component.metadata?.blockId || component.id),
diff --git a/lib/detail-ui-surface-adapter.ts b/lib/detail-ui-surface-adapter.ts
index ac1b4c695..ee8c0c1b0 100644
--- a/lib/detail-ui-surface-adapter.ts
+++ b/lib/detail-ui-surface-adapter.ts
@@ -12,12 +12,6 @@ import type {
UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
-function mapBlockTypeToBindingKey(blockType: "title" | "subtitle" | "body") {
- if (blockType === "title") return "title";
- if (blockType === "subtitle") return "subtitle";
- return "copy";
-}
-
function createDetailSurfaceId(params: {
generationMode: DetailGenerationMode;
screenCount: number;
@@ -36,12 +30,426 @@ function createRootComponent(): UiSurfaceComponent {
};
}
+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";
+ parentId: string;
+ frame: { x: number; y: number; width: number; height: number };
+ zIndex?: number;
+ content?: string;
+ bindingPath?: string;
+ role: string;
+ style: Record;
+ metadata?: Record;
+}) {
+ 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 createSectionComponent(params: {
+ id: string;
+ parentId: string;
+ role: string;
+ frame: { x: number; y: number; width: number; height: number };
+ zIndex?: number;
+ metadata?: Record;
+}) {
+ 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;
+ 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 bodyHighlights = splitBodyCopyIntoHighlights(params.screen.copy);
+
+ 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 =
+ bodyHighlights.length > 0
+ ? bodyHighlights.slice(0, 3)
+ : [String(params.screen.copy || "").trim()].filter(Boolean);
+ 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}-support-card-${index + 1}`,
+ type: "detail_card",
+ parentId: `${params.screenId}-section-supporting`,
+ frame: {
+ x,
+ y,
+ width: supportCardWidth,
+ height: variant === "split_bottom" ? 102 : 96,
+ },
+ zIndex: 2,
+ content: highlight,
+ role: "support_card",
+ style: {
+ fontSize: bodyBlock ? Math.max(18, Number(bodyBlock.style.fontSize || 22)) : 20,
+ fontWeight: "600",
+ color: "#111827",
+ textAlign: "left",
+ lineHeight: 1.32,
+ backgroundColor:
+ variant === "split_bottom"
+ ? "rgba(255,255,255,0.92)"
+ : "rgba(248,250,252,0.82)",
+ borderColor:
+ index === 0 ? "rgba(59,130,246,0.42)" : "rgba(148,163,184,0.28)",
+ borderWidth: 1,
+ borderRadius: 24,
+ padding: 18,
+ boxShadow: "0 18px 42px rgba(15,23,42,0.12)",
+ backdropBlur: 12,
+ },
+ metadata: {
+ screenIndex: params.screenIndex,
+ componentRole: "support_card",
+ },
+ }),
+ );
+ });
+
+ if (params.screen.layoutNote) {
+ const noteY = Math.min(
+ params.screenSize.height - 116,
+ supportingSectionY + supportingSectionHeight - 74,
+ );
+ components.push(
+ createStyledSurfaceTextComponent({
+ id: `${params.screenId}-proof-card`,
+ type: "detail_card",
+ 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: String(params.screen.layoutNote || "").replace(/\s+/g, " ").trim(),
+ role: "proof_chip",
+ 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_chip",
+ },
+ }),
+ );
+ }
+
+ return components;
+}
+
function createScreenSubtree(params: {
screenIndex: number;
screen: Pick<
DetailUiScreenInput,
"title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
+ screenSize: { width: number; height: number };
screenProtocol?: DetailUiScreenProtocol;
rootId?: string;
}) {
@@ -91,42 +499,16 @@ function createScreenSubtree(params: {
},
];
- const blocks = params.screenProtocol?.blocks || [];
- blocks.forEach((block, blockIndex) => {
- const bindingKey = mapBlockTypeToBindingKey(block.type);
- components.push({
- id: `${screenId}-text-${blockIndex + 1}`,
- type: "detail_text_block",
- parentId: textStackId,
- props: {
- blockType: block.type,
- frame: block.frame,
- textKey: bindingKey,
- },
- bindings: {
- content: {
- kind: "text",
- path: `/screens/${params.screenIndex - 1}/${bindingKey}`,
- },
- },
- style: {
- fontFamily: block.style.fontFamily || "",
- fontSize: block.style.fontSize,
- fontWeight: block.style.fontWeight,
- lineHeight: block.style.lineHeight,
- color: block.style.color,
- textAlign: block.style.textAlign,
- },
- layout: {
- frame: block.frame,
- },
- metadata: {
- screenIndex: params.screenIndex,
- blockType: block.type,
- blockId: block.id,
- },
- });
- });
+ components.push(
+ ...buildSectionedTextComponents({
+ screenId,
+ textStackId,
+ screenIndex: params.screenIndex,
+ screenSize: params.screenSize,
+ screen: params.screen,
+ screenProtocol: params.screenProtocol,
+ }),
+ );
return components;
}
@@ -180,6 +562,7 @@ export function buildDetailUiSurfaceSnapshot(params: {
...createScreenSubtree({
screenIndex: screen.index,
screen,
+ screenSize: params.screenSize,
screenProtocol,
}),
);
@@ -231,9 +614,17 @@ export function buildDetailUiSurfacePatchFromReview(params: {
const replacementComponents = createScreenSubtree({
screenIndex: params.screenIndex,
screen: params.screen,
+ screenSize: {
+ width: 790,
+ height: 1300,
+ },
screenProtocol: params.uiPatch.recommendedScreen,
rootId: screenId,
- }).filter((component) => component.id === textStackId || component.parentId === textStackId);
+ }).filter(
+ (component) =>
+ component.id !== screenId &&
+ component.id !== `${screenId}-background`,
+ );
return {
version: "ui-surface-patch/v1",
diff --git a/lib/ui-surface-protocol.ts b/lib/ui-surface-protocol.ts
index 10f451438..38445b9ff 100644
--- a/lib/ui-surface-protocol.ts
+++ b/lib/ui-surface-protocol.ts
@@ -7,7 +7,10 @@ export type UiSurfaceComponentType =
| "detail_screen"
| "detail_background_image"
| "detail_text_stack"
- | "detail_text_block";
+ | "detail_text_block"
+ | "detail_section"
+ | "detail_card"
+ | "detail_badge";
export type UiSurfaceTextBindingKey = "title" | "subtitle" | "copy";
diff --git a/types/canvas/index.ts b/types/canvas/index.ts
index ff6c8c2f9..9605306cc 100644
--- a/types/canvas/index.ts
+++ b/types/canvas/index.ts
@@ -77,11 +77,21 @@ export interface VideoNodeData {
export interface TextNodeData {
content: string;
fontSize?: number;
+ fontFamily?: string;
color?: string;
fontWeight?: string;
lineHeight?: number;
+ letterSpacing?: number;
textAlign?: "left" | "center" | "right";
transparent?: boolean;
+ backgroundColor?: string;
+ borderColor?: string;
+ borderWidth?: number;
+ borderRadius?: number;
+ padding?: number;
+ boxShadow?: string;
+ backdropBlur?: number;
+ textTransform?: "none" | "uppercase" | "lowercase" | "capitalize";
}
// 场景裂变节点数据