feat: add subject-safe detail overlay templates
This commit is contained in:
parent
be58859906
commit
139b14b3c6
@ -7,6 +7,10 @@ import type {
|
||||
DetailUiScreenReviewPatch,
|
||||
} from "@/lib/detail-ui-protocol";
|
||||
import { getDetailUiDesignTokens } from "@/lib/detail-ui-design-tokens";
|
||||
import {
|
||||
moveFrameForSubjectAvoidance,
|
||||
resolveDetailUiTemplate,
|
||||
} from "@/lib/detail-ui-templates";
|
||||
import type {
|
||||
UiSurfaceComponent,
|
||||
UiSurfacePatch,
|
||||
@ -256,6 +260,11 @@ function buildSectionedTextComponents(params: {
|
||||
screenIndex: params.screenIndex,
|
||||
screenType: params.screen.screenType,
|
||||
});
|
||||
const template = resolveDetailUiTemplate({
|
||||
screenIndex: params.screenIndex,
|
||||
totalScreens: params.totalScreens,
|
||||
screenType: params.screen.screenType,
|
||||
});
|
||||
const tokens = getDetailUiDesignTokens({
|
||||
variant,
|
||||
screenType: params.screen.screenType,
|
||||
@ -279,13 +288,22 @@ function buildSectionedTextComponents(params: {
|
||||
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 titleFrame = moveFrameForSubjectAvoidance(
|
||||
{
|
||||
...template.titleFrame,
|
||||
height: Math.max(template.titleFrame.height, Math.round(Number(titleBlock?.frame.height || template.titleFrame.height))),
|
||||
},
|
||||
template.subjectAvoidanceBox,
|
||||
variant === "hero_right" ? "right" : "left",
|
||||
);
|
||||
const subtitleFrame = moveFrameForSubjectAvoidance(
|
||||
{
|
||||
...template.subtitleFrame,
|
||||
height: Math.max(template.subtitleFrame.height, Math.round(Number(subtitleBlock?.frame.height || template.subtitleFrame.height))),
|
||||
},
|
||||
template.subjectAvoidanceBox,
|
||||
variant === "hero_right" ? "right" : "left",
|
||||
);
|
||||
|
||||
const components: UiSurfaceComponent[] = [];
|
||||
|
||||
@ -295,10 +313,10 @@ function buildSectionedTextComponents(params: {
|
||||
parentId: params.textStackId,
|
||||
role: "hero_section",
|
||||
frame: {
|
||||
x: cardX,
|
||||
y: safeArea.top,
|
||||
width: cardWidth,
|
||||
height: Math.min(360, params.screenSize.height - safeArea.top - 120),
|
||||
x: Math.min(titleFrame.x, template.badgeFrame.x),
|
||||
y: template.badgeFrame.y,
|
||||
width: Math.max(titleFrame.width, subtitleFrame.width),
|
||||
height: subtitleFrame.y + subtitleFrame.height - template.badgeFrame.y,
|
||||
},
|
||||
metadata: {
|
||||
screenIndex: params.screenIndex,
|
||||
@ -313,10 +331,8 @@ function buildSectionedTextComponents(params: {
|
||||
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,
|
||||
...template.badgeFrame,
|
||||
width: Math.min(template.badgeFrame.width, Math.max(104, badgeLabel.length * 18)),
|
||||
},
|
||||
zIndex: 3,
|
||||
content: badgeLabel,
|
||||
@ -346,15 +362,10 @@ function buildSectionedTextComponents(params: {
|
||||
|
||||
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)),
|
||||
},
|
||||
id: `${params.screenId}-title-card`,
|
||||
type: "detail_card",
|
||||
parentId: `${params.screenId}-section-hero`,
|
||||
frame: titleFrame,
|
||||
zIndex: 2,
|
||||
bindingPath: `/screens/${params.screenIndex - 1}/title`,
|
||||
role: "title_card",
|
||||
@ -387,12 +398,7 @@ function buildSectionedTextComponents(params: {
|
||||
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)),
|
||||
},
|
||||
frame: subtitleFrame,
|
||||
zIndex: 2,
|
||||
bindingPath: `/screens/${params.screenIndex - 1}/subtitle`,
|
||||
role: "subtitle_card",
|
||||
@ -419,24 +425,39 @@ function buildSectionedTextComponents(params: {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
const supportingSectionFrame = {
|
||||
x: Math.min(...template.featureCardFrames.map((frame) => frame.x)),
|
||||
y: Math.min(
|
||||
...template.featureCardFrames.map((frame) => frame.y),
|
||||
template.specTableSectionFrame.y,
|
||||
template.comparisonBeforeFrame.y,
|
||||
template.proofFrame.y,
|
||||
),
|
||||
width: Math.max(
|
||||
...template.featureCardFrames.map((frame) => frame.x + frame.width),
|
||||
template.specTableSectionFrame.x + template.specTableSectionFrame.width,
|
||||
template.comparisonAfterFrame.x + template.comparisonAfterFrame.width,
|
||||
) - Math.min(...template.featureCardFrames.map((frame) => frame.x)),
|
||||
height:
|
||||
Math.max(
|
||||
...template.featureCardFrames.map((frame) => frame.y + frame.height),
|
||||
template.specTableSectionFrame.y + template.specTableSectionFrame.height,
|
||||
template.comparisonAfterFrame.y + template.comparisonAfterFrame.height,
|
||||
template.ctaBannerFrame.y + template.ctaBannerFrame.height,
|
||||
) -
|
||||
Math.min(
|
||||
...template.featureCardFrames.map((frame) => frame.y),
|
||||
template.specTableSectionFrame.y,
|
||||
template.comparisonBeforeFrame.y,
|
||||
),
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
frame: supportingSectionFrame,
|
||||
zIndex: 1,
|
||||
metadata: {
|
||||
screenIndex: params.screenIndex,
|
||||
@ -456,17 +477,9 @@ function buildSectionedTextComponents(params: {
|
||||
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;
|
||||
const tagFrame = template.featureTagFrames[index] || template.featureTagFrames[template.featureTagFrames.length - 1]!;
|
||||
const cardFrame = template.featureCardFrames[index] || template.featureCardFrames[template.featureCardFrames.length - 1]!;
|
||||
|
||||
components.push(
|
||||
createStyledSurfaceTextComponent({
|
||||
@ -474,10 +487,8 @@ function buildSectionedTextComponents(params: {
|
||||
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,
|
||||
...tagFrame,
|
||||
width: Math.min(tagFrame.width, Math.max(76, highlight.tag.length * 17)),
|
||||
},
|
||||
zIndex: 3,
|
||||
content: `✦ ${highlight.tag}`,
|
||||
@ -521,12 +532,7 @@ function buildSectionedTextComponents(params: {
|
||||
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,
|
||||
},
|
||||
frame: cardFrame,
|
||||
zIndex: 2,
|
||||
content: `${index + 1}. ${highlight.title}${highlight.detail ? `\n${highlight.detail}` : ""}`,
|
||||
role: "support_card",
|
||||
@ -570,12 +576,9 @@ function buildSectionedTextComponents(params: {
|
||||
});
|
||||
|
||||
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 tableX = template.specTableSectionFrame.x;
|
||||
const tableY = template.specTableSectionFrame.y;
|
||||
const tableWidth = template.specTableSectionFrame.width;
|
||||
const labelWidth = 106;
|
||||
const rowGap = 10;
|
||||
|
||||
@ -585,10 +588,7 @@ function buildSectionedTextComponents(params: {
|
||||
parentId: `${params.screenId}-section-supporting`,
|
||||
role: "spec_table_section",
|
||||
frame: {
|
||||
x: tableX,
|
||||
y: tableY,
|
||||
width: tableWidth,
|
||||
height: 196,
|
||||
...template.specTableSectionFrame,
|
||||
},
|
||||
zIndex: 2,
|
||||
metadata: {
|
||||
@ -670,20 +670,12 @@ function buildSectionedTextComponents(params: {
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
frame: template.comparisonBeforeFrame,
|
||||
zIndex: 2,
|
||||
content: `${catalog.comparison.beforeTitle}\n${catalog.comparison.beforeBody}`,
|
||||
role: "comparison_before",
|
||||
@ -713,12 +705,7 @@ function buildSectionedTextComponents(params: {
|
||||
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,
|
||||
},
|
||||
frame: template.comparisonAfterFrame,
|
||||
zIndex: 2,
|
||||
content: `${catalog.comparison.afterTitle}\n${catalog.comparison.afterBody}`,
|
||||
role: "comparison_after",
|
||||
@ -745,24 +732,12 @@ function buildSectionedTextComponents(params: {
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
frame: template.proofFrame,
|
||||
zIndex: 2,
|
||||
content: `${catalog.proof.label}|${catalog.proof.value}`,
|
||||
role: "proof_bar",
|
||||
@ -794,12 +769,7 @@ function buildSectionedTextComponents(params: {
|
||||
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,
|
||||
},
|
||||
frame: template.ctaSupportFrame,
|
||||
zIndex: 2,
|
||||
content: catalog.cta.highlightText,
|
||||
role: "cta_support",
|
||||
@ -829,12 +799,7 @@ function buildSectionedTextComponents(params: {
|
||||
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,
|
||||
},
|
||||
frame: template.ctaButtonFrame,
|
||||
zIndex: 3,
|
||||
content: `${catalog.cta.buttonLabel} →`,
|
||||
role: "cta_button",
|
||||
@ -866,12 +831,7 @@ function buildSectionedTextComponents(params: {
|
||||
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,
|
||||
},
|
||||
frame: template.ctaBannerFrame,
|
||||
zIndex: 2,
|
||||
content: catalog.cta.supportText,
|
||||
role: "cta_banner",
|
||||
|
||||
214
lib/detail-ui-templates.ts
Normal file
214
lib/detail-ui-templates.ts
Normal file
@ -0,0 +1,214 @@
|
||||
export type DetailUiTemplateId =
|
||||
| "hero_poster_left"
|
||||
| "hero_poster_right"
|
||||
| "compare_dual_column"
|
||||
| "feature_grid_bottom"
|
||||
| "cta_focus";
|
||||
|
||||
export interface DetailUiTemplateFrame {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DetailUiTemplateSpec {
|
||||
id: DetailUiTemplateId;
|
||||
subjectAvoidanceBox: DetailUiTemplateFrame;
|
||||
badgeFrame: DetailUiTemplateFrame;
|
||||
titleFrame: DetailUiTemplateFrame;
|
||||
subtitleFrame: DetailUiTemplateFrame;
|
||||
featureTagFrames: DetailUiTemplateFrame[];
|
||||
featureCardFrames: DetailUiTemplateFrame[];
|
||||
specTableSectionFrame: DetailUiTemplateFrame;
|
||||
comparisonBeforeFrame: DetailUiTemplateFrame;
|
||||
comparisonAfterFrame: DetailUiTemplateFrame;
|
||||
proofFrame: DetailUiTemplateFrame;
|
||||
ctaSupportFrame: DetailUiTemplateFrame;
|
||||
ctaButtonFrame: DetailUiTemplateFrame;
|
||||
ctaBannerFrame: DetailUiTemplateFrame;
|
||||
}
|
||||
|
||||
const CANVAS_WIDTH = 790;
|
||||
const CANVAS_HEIGHT = 1300;
|
||||
const SUBJECT_BOX_CENTER: DetailUiTemplateFrame = {
|
||||
x: 170,
|
||||
y: 360,
|
||||
width: 460,
|
||||
height: 800,
|
||||
};
|
||||
|
||||
const TEMPLATE_REGISTRY: Record<DetailUiTemplateId, DetailUiTemplateSpec> = {
|
||||
hero_poster_left: {
|
||||
id: "hero_poster_left",
|
||||
subjectAvoidanceBox: SUBJECT_BOX_CENTER,
|
||||
badgeFrame: { x: 56, y: 56, width: 120, height: 36 },
|
||||
titleFrame: { x: 56, y: 110, width: 390, height: 136 },
|
||||
subtitleFrame: { x: 56, y: 264, width: 338, height: 88 },
|
||||
featureTagFrames: [
|
||||
{ x: 56, y: 384, width: 96, height: 28 },
|
||||
{ x: 56, y: 510, width: 96, height: 28 },
|
||||
{ x: 56, y: 636, width: 96, height: 28 },
|
||||
],
|
||||
featureCardFrames: [
|
||||
{ x: 56, y: 404, width: 310, height: 114 },
|
||||
{ x: 56, y: 530, width: 310, height: 114 },
|
||||
{ x: 56, y: 656, width: 310, height: 114 },
|
||||
],
|
||||
specTableSectionFrame: { x: 56, y: 794, width: 330, height: 210 },
|
||||
comparisonBeforeFrame: { x: 56, y: 794, width: 156, height: 164 },
|
||||
comparisonAfterFrame: { x: 228, y: 794, width: 158, height: 164 },
|
||||
proofFrame: { x: 56, y: 1022, width: 330, height: 64 },
|
||||
ctaSupportFrame: { x: 56, y: 1104, width: 330, height: 68 },
|
||||
ctaButtonFrame: { x: 56, y: 1186, width: 206, height: 56 },
|
||||
ctaBannerFrame: { x: 274, y: 1186, width: 188, height: 56 },
|
||||
},
|
||||
hero_poster_right: {
|
||||
id: "hero_poster_right",
|
||||
subjectAvoidanceBox: SUBJECT_BOX_CENTER,
|
||||
badgeFrame: { x: 618, y: 56, width: 116, height: 36 },
|
||||
titleFrame: { x: 366, y: 110, width: 368, height: 136 },
|
||||
subtitleFrame: { x: 410, y: 264, width: 324, height: 88 },
|
||||
featureTagFrames: [
|
||||
{ x: 422, y: 384, width: 92, height: 28 },
|
||||
{ x: 422, y: 510, width: 92, height: 28 },
|
||||
{ x: 422, y: 636, width: 92, height: 28 },
|
||||
],
|
||||
featureCardFrames: [
|
||||
{ x: 422, y: 404, width: 312, height: 114 },
|
||||
{ x: 422, y: 530, width: 312, height: 114 },
|
||||
{ x: 422, y: 656, width: 312, height: 114 },
|
||||
],
|
||||
specTableSectionFrame: { x: 404, y: 794, width: 330, height: 210 },
|
||||
comparisonBeforeFrame: { x: 404, y: 794, width: 156, height: 164 },
|
||||
comparisonAfterFrame: { x: 576, y: 794, width: 158, height: 164 },
|
||||
proofFrame: { x: 404, y: 1022, width: 330, height: 64 },
|
||||
ctaSupportFrame: { x: 348, y: 1104, width: 386, height: 68 },
|
||||
ctaButtonFrame: { x: 528, y: 1186, width: 206, height: 56 },
|
||||
ctaBannerFrame: { x: 348, y: 1186, width: 168, height: 56 },
|
||||
},
|
||||
compare_dual_column: {
|
||||
id: "compare_dual_column",
|
||||
subjectAvoidanceBox: SUBJECT_BOX_CENTER,
|
||||
badgeFrame: { x: 56, y: 56, width: 120, height: 36 },
|
||||
titleFrame: { x: 56, y: 108, width: 340, height: 122 },
|
||||
subtitleFrame: { x: 56, y: 246, width: 340, height: 78 },
|
||||
featureTagFrames: [
|
||||
{ x: 56, y: 350, width: 92, height: 28 },
|
||||
{ x: 56, y: 722, width: 92, height: 28 },
|
||||
{ x: 56, y: 1096, width: 92, height: 28 },
|
||||
],
|
||||
featureCardFrames: [
|
||||
{ x: 56, y: 376, width: 330, height: 110 },
|
||||
{ x: 56, y: 748, width: 330, height: 110 },
|
||||
{ x: 56, y: 1122, width: 330, height: 94 },
|
||||
],
|
||||
specTableSectionFrame: { x: 404, y: 350, width: 330, height: 208 },
|
||||
comparisonBeforeFrame: { x: 404, y: 350, width: 156, height: 178 },
|
||||
comparisonAfterFrame: { x: 576, y: 350, width: 158, height: 178 },
|
||||
proofFrame: { x: 404, y: 560, width: 330, height: 64 },
|
||||
ctaSupportFrame: { x: 404, y: 640, width: 330, height: 68 },
|
||||
ctaButtonFrame: { x: 404, y: 726, width: 194, height: 56 },
|
||||
ctaBannerFrame: { x: 610, y: 726, width: 124, height: 56 },
|
||||
},
|
||||
feature_grid_bottom: {
|
||||
id: "feature_grid_bottom",
|
||||
subjectAvoidanceBox: SUBJECT_BOX_CENTER,
|
||||
badgeFrame: { x: 56, y: 56, width: 120, height: 36 },
|
||||
titleFrame: { x: 56, y: 104, width: 420, height: 126 },
|
||||
subtitleFrame: { x: 56, y: 248, width: 360, height: 84 },
|
||||
featureTagFrames: [
|
||||
{ x: 56, y: 884, width: 92, height: 28 },
|
||||
{ x: 314, y: 884, width: 92, height: 28 },
|
||||
{ x: 572, y: 884, width: 92, height: 28 },
|
||||
],
|
||||
featureCardFrames: [
|
||||
{ x: 56, y: 906, width: 220, height: 124 },
|
||||
{ x: 286, y: 906, width: 220, height: 124 },
|
||||
{ x: 516, y: 906, width: 218, height: 124 },
|
||||
],
|
||||
specTableSectionFrame: { x: 56, y: 1048, width: 398, height: 196 },
|
||||
comparisonBeforeFrame: { x: 470, y: 1048, width: 128, height: 176 },
|
||||
comparisonAfterFrame: { x: 606, y: 1048, width: 128, height: 176 },
|
||||
proofFrame: { x: 56, y: 1254, width: 330, height: 40 },
|
||||
ctaSupportFrame: { x: 398, y: 1234, width: 336, height: 52 },
|
||||
ctaButtonFrame: { x: 536, y: 1168, width: 198, height: 50 },
|
||||
ctaBannerFrame: { x: 398, y: 1168, width: 126, height: 50 },
|
||||
},
|
||||
cta_focus: {
|
||||
id: "cta_focus",
|
||||
subjectAvoidanceBox: SUBJECT_BOX_CENTER,
|
||||
badgeFrame: { x: 56, y: 56, width: 120, height: 36 },
|
||||
titleFrame: { x: 56, y: 108, width: 420, height: 126 },
|
||||
subtitleFrame: { x: 56, y: 248, width: 360, height: 84 },
|
||||
featureTagFrames: [
|
||||
{ x: 56, y: 860, width: 92, height: 28 },
|
||||
{ x: 56, y: 982, width: 92, height: 28 },
|
||||
{ x: 56, y: 1104, width: 92, height: 28 },
|
||||
],
|
||||
featureCardFrames: [
|
||||
{ x: 56, y: 882, width: 338, height: 110 },
|
||||
{ x: 56, y: 1004, width: 338, height: 110 },
|
||||
{ x: 56, y: 1126, width: 338, height: 110 },
|
||||
],
|
||||
specTableSectionFrame: { x: 414, y: 870, width: 320, height: 180 },
|
||||
comparisonBeforeFrame: { x: 414, y: 1062, width: 152, height: 154 },
|
||||
comparisonAfterFrame: { x: 582, y: 1062, width: 152, height: 154 },
|
||||
proofFrame: { x: 414, y: 882, width: 320, height: 60 },
|
||||
ctaSupportFrame: { x: 414, y: 954, width: 320, height: 68 },
|
||||
ctaButtonFrame: { x: 414, y: 1140, width: 320, height: 56 },
|
||||
ctaBannerFrame: { x: 414, y: 1206, width: 320, height: 48 },
|
||||
},
|
||||
};
|
||||
|
||||
function rectsIntersect(a: DetailUiTemplateFrame, b: DetailUiTemplateFrame) {
|
||||
return (
|
||||
a.x < b.x + b.width &&
|
||||
a.x + a.width > b.x &&
|
||||
a.y < b.y + b.height &&
|
||||
a.y + a.height > b.y
|
||||
);
|
||||
}
|
||||
|
||||
function clampFrame(frame: DetailUiTemplateFrame) {
|
||||
return {
|
||||
x: Math.max(20, Math.min(CANVAS_WIDTH - frame.width - 20, frame.x)),
|
||||
y: Math.max(20, Math.min(CANVAS_HEIGHT - frame.height - 20, frame.y)),
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function moveFrameForSubjectAvoidance(
|
||||
frame: DetailUiTemplateFrame,
|
||||
avoid: DetailUiTemplateFrame,
|
||||
preference: "top" | "left" | "right" | "bottom",
|
||||
) {
|
||||
if (!rectsIntersect(frame, avoid)) return clampFrame(frame);
|
||||
|
||||
const gap = 18;
|
||||
if (preference === "top") {
|
||||
return clampFrame({ ...frame, y: avoid.y - frame.height - gap });
|
||||
}
|
||||
if (preference === "left") {
|
||||
return clampFrame({ ...frame, x: avoid.x - frame.width - gap });
|
||||
}
|
||||
if (preference === "right") {
|
||||
return clampFrame({ ...frame, x: avoid.x + avoid.width + gap });
|
||||
}
|
||||
return clampFrame({ ...frame, y: avoid.y + avoid.height + gap });
|
||||
}
|
||||
|
||||
export function resolveDetailUiTemplate(params: {
|
||||
screenIndex: number;
|
||||
totalScreens?: number;
|
||||
screenType?: string;
|
||||
}): DetailUiTemplateSpec {
|
||||
if ((params.totalScreens || 0) > 0 && params.screenIndex === params.totalScreens) {
|
||||
return TEMPLATE_REGISTRY.cta_focus;
|
||||
}
|
||||
if (params.screenType === "A") return TEMPLATE_REGISTRY.hero_poster_right;
|
||||
if (params.screenType === "C") return TEMPLATE_REGISTRY.compare_dual_column;
|
||||
if (params.screenIndex <= 2) return TEMPLATE_REGISTRY.hero_poster_left;
|
||||
return TEMPLATE_REGISTRY.feature_grid_bottom;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user