298 lines
7.8 KiB
TypeScript
298 lines
7.8 KiB
TypeScript
import type {
|
|
DetailGenerationMode,
|
|
DetailUiGlobalStyle,
|
|
DetailUiProtocol,
|
|
DetailUiScreenInput,
|
|
DetailUiScreenProtocol,
|
|
DetailUiScreenReviewPatch,
|
|
} from "@/lib/detail-ui-protocol";
|
|
import type {
|
|
UiSurfaceComponent,
|
|
UiSurfacePatch,
|
|
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;
|
|
}) {
|
|
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",
|
|
},
|
|
};
|
|
}
|
|
|
|
function createScreenSubtree(params: {
|
|
screenIndex: number;
|
|
screen: Pick<
|
|
DetailUiScreenInput,
|
|
"title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
|
|
>;
|
|
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,
|
|
},
|
|
},
|
|
];
|
|
|
|
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,
|
|
},
|
|
});
|
|
});
|
|
|
|
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,
|
|
screen,
|
|
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,
|
|
screen: params.screen,
|
|
screenProtocol: params.uiPatch.recommendedScreen,
|
|
rootId: screenId,
|
|
}).filter((component) => component.id === textStackId || component.parentId === textStackId);
|
|
|
|
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
|
|
);
|
|
}
|