diff --git a/lib/detail-ui-surface-adapter.ts b/lib/detail-ui-surface-adapter.ts index 40ac7f562..bf2ac805a 100644 --- a/lib/detail-ui-surface-adapter.ts +++ b/lib/detail-ui-surface-adapter.ts @@ -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", diff --git a/lib/detail-ui-templates.ts b/lib/detail-ui-templates.ts new file mode 100644 index 000000000..a371a947c --- /dev/null +++ b/lib/detail-ui-templates.ts @@ -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 = { + 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; +}