feat: add subject-safe detail overlay templates
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-22 02:29:09 +08:00
parent be58859906
commit 139b14b3c6
2 changed files with 292 additions and 118 deletions

View File

@ -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
View 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;
}