feat: add detail overlay design tokens
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:16:55 +08:00
parent f40489d4d7
commit be58859906
3 changed files with 421 additions and 78 deletions

View File

@ -1,6 +1,7 @@
"use client";
import type { CSSProperties } from "react";
import { ArrowRight, CheckCircle2, ShieldCheck, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
@ -58,6 +59,90 @@ function resolveOverlayText(
return String(component.props?.content || "");
}
function renderOverlayComponentContent(
snapshot: DetailUiScreenOverlaySnapshot,
component: RenderableOverlayComponent,
) {
const content = resolveOverlayText(snapshot, component);
const lines = content.split("\n").filter(Boolean);
if (component.type === "detail_feature_card") {
const headline = lines[0] || content;
const detail = lines.slice(1).join("\n");
return (
<div className="flex h-full w-full flex-col justify-between">
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-2xl bg-white/70 p-2 text-blue-600">
<Sparkles className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[0.95em] font-extrabold leading-[1.2]">{headline}</div>
{detail ? (
<div className="mt-2 text-[0.72em] font-medium leading-[1.35] opacity-85">
{detail}
</div>
) : null}
</div>
</div>
</div>
);
}
if (component.type === "detail_feature_tag") {
return (
<div className="flex h-full w-full items-center justify-center gap-1 whitespace-nowrap">
<Sparkles className="h-3.5 w-3.5" />
<span>{content}</span>
</div>
);
}
if (component.type === "detail_compare_before" || component.type === "detail_compare_after") {
const title = lines[0] || "";
const body = lines.slice(1).join("\n");
return (
<div className="flex h-full w-full flex-col">
<div className="text-[0.78em] font-extrabold uppercase tracking-[0.08em] opacity-80">
{title}
</div>
<div className="mt-3 h-px w-full bg-current/15" />
<div className="mt-3 text-[0.82em] font-semibold leading-[1.42]">
{body}
</div>
</div>
);
}
if (component.type === "detail_proof_bar") {
return (
<div className="flex h-full w-full items-center gap-3">
<ShieldCheck className="h-4.5 w-4.5 shrink-0" />
<div className="min-w-0 text-[0.86em] font-semibold leading-[1.2]">{content}</div>
</div>
);
}
if (component.type === "detail_cta_button") {
return (
<div className="flex h-full w-full items-center justify-center gap-2 whitespace-nowrap">
<span>{content}</span>
<ArrowRight className="h-4.5 w-4.5" />
</div>
);
}
if (component.type === "detail_cta_support" || component.type === "detail_cta_banner") {
return (
<div className="flex h-full w-full items-center justify-between gap-3">
<div className="min-w-0 text-[0.82em] font-semibold leading-[1.25]">{content}</div>
{component.type === "detail_cta_banner" ? <CheckCircle2 className="h-4.5 w-4.5 shrink-0" /> : null}
</div>
);
}
return content;
}
function getOverlayComponentStyle(params: {
component: RenderableOverlayComponent;
snapshot: DetailUiScreenOverlaySnapshot;
@ -198,7 +283,7 @@ export function DetailSurfaceOverlayRenderer(params: {
content,
})}
>
{content}
{renderOverlayComponentContent(params.overlay!, component)}
</div>
);
})}

View File

@ -0,0 +1,244 @@
export type DetailUiTokenVariant = "hero_left" | "hero_right" | "split_bottom";
export interface DetailUiDesignTokens {
canvas: {
sectionGap: number;
sectionRadius: number;
};
typography: {
title: {
fontSize: number;
fontWeight: string;
lineHeight: number;
letterSpacing: number;
color: string;
};
subtitle: {
fontSize: number;
fontWeight: string;
lineHeight: number;
color: string;
};
body: {
fontSize: number;
fontWeight: string;
lineHeight: number;
color: string;
};
label: {
fontSize: number;
fontWeight: string;
lineHeight: number;
color: string;
};
};
badge: {
backgroundColor: string;
color: string;
borderColor: string;
};
heroCard: {
backgroundColor: string;
borderColor: string;
shadow: string;
};
subtitleCard: {
backgroundColor: string;
borderColor: string;
shadow: string;
color: string;
};
featureTag: {
primary: { backgroundColor: string; color: string; borderColor: string };
secondary: { backgroundColor: string; color: string; borderColor: string };
neutral: { backgroundColor: string; color: string; borderColor: string };
};
featureCard: {
primary: { backgroundColor: string; borderColor: string; shadow: string };
secondary: { backgroundColor: string; borderColor: string; shadow: string };
neutral: { backgroundColor: string; borderColor: string; shadow: string };
};
table: {
labelBackground: string;
labelBorder: string;
valueBackground: string;
valueBorder: string;
sectionShadow: string;
};
compare: {
beforeBackground: string;
beforeBorder: string;
beforeColor: string;
afterBackground: string;
afterBorder: string;
afterColor: string;
};
proof: {
backgroundColor: string;
borderColor: string;
color: string;
};
cta: {
supportBackground: string;
supportBorder: string;
supportColor: string;
buttonBackground: string;
buttonBorder: string;
buttonColor: string;
bannerBackground: string;
bannerBorder: string;
bannerColor: string;
};
}
const BASE_TOKENS: DetailUiDesignTokens = {
canvas: {
sectionGap: 16,
sectionRadius: 28,
},
typography: {
title: {
fontSize: 52,
fontWeight: "800",
lineHeight: 1.08,
letterSpacing: -0.8,
color: "#0f172a",
},
subtitle: {
fontSize: 28,
fontWeight: "700",
lineHeight: 1.18,
color: "#f8fafc",
},
body: {
fontSize: 18,
fontWeight: "600",
lineHeight: 1.34,
color: "#111827",
},
label: {
fontSize: 14,
fontWeight: "700",
lineHeight: 1.15,
color: "#334155",
},
},
badge: {
backgroundColor: "rgba(15,23,42,0.82)",
color: "#f8fafc",
borderColor: "rgba(255,255,255,0.18)",
},
heroCard: {
backgroundColor: "rgba(255,255,255,0.88)",
borderColor: "rgba(255,255,255,0.42)",
shadow: "0 22px 58px rgba(15,23,42,0.18)",
},
subtitleCard: {
backgroundColor: "rgba(30,41,59,0.78)",
borderColor: "rgba(255,255,255,0.18)",
shadow: "0 18px 42px rgba(15,23,42,0.18)",
color: "#f8fafc",
},
featureTag: {
primary: {
backgroundColor: "rgba(219,234,254,0.96)",
color: "#1d4ed8",
borderColor: "rgba(59,130,246,0.24)",
},
secondary: {
backgroundColor: "rgba(237,233,254,0.96)",
color: "#7c3aed",
borderColor: "rgba(124,58,237,0.24)",
},
neutral: {
backgroundColor: "rgba(241,245,249,0.96)",
color: "#334155",
borderColor: "rgba(148,163,184,0.24)",
},
},
featureCard: {
primary: {
backgroundColor: "rgba(255,255,255,0.94)",
borderColor: "rgba(59,130,246,0.42)",
shadow: "0 18px 42px rgba(15,23,42,0.12)",
},
secondary: {
backgroundColor: "rgba(248,250,252,0.92)",
borderColor: "rgba(124,58,237,0.34)",
shadow: "0 18px 42px rgba(15,23,42,0.10)",
},
neutral: {
backgroundColor: "rgba(248,250,252,0.82)",
borderColor: "rgba(148,163,184,0.28)",
shadow: "0 18px 42px rgba(15,23,42,0.10)",
},
},
table: {
labelBackground: "rgba(241,245,249,0.96)",
labelBorder: "rgba(148,163,184,0.22)",
valueBackground: "rgba(255,255,255,0.94)",
valueBorder: "rgba(59,130,246,0.16)",
sectionShadow: "0 18px 42px rgba(15,23,42,0.12)",
},
compare: {
beforeBackground: "rgba(255,255,255,0.92)",
beforeBorder: "rgba(148,163,184,0.20)",
beforeColor: "#334155",
afterBackground: "rgba(37,99,235,0.88)",
afterBorder: "rgba(255,255,255,0.18)",
afterColor: "#eff6ff",
},
proof: {
backgroundColor: "rgba(255,255,255,0.88)",
borderColor: "rgba(15,23,42,0.10)",
color: "#1e293b",
},
cta: {
supportBackground: "rgba(255,255,255,0.88)",
supportBorder: "rgba(37,99,235,0.14)",
supportColor: "#0f172a",
buttonBackground: "rgba(37,99,235,0.94)",
buttonBorder: "rgba(255,255,255,0.24)",
buttonColor: "#f8fafc",
bannerBackground: "rgba(15,23,42,0.74)",
bannerBorder: "rgba(255,255,255,0.16)",
bannerColor: "#eff6ff",
},
};
export function getDetailUiDesignTokens(params: {
variant: DetailUiTokenVariant;
screenType?: string;
isFirstScreen?: boolean;
}) {
const tokens: DetailUiDesignTokens = JSON.parse(JSON.stringify(BASE_TOKENS));
if (params.variant === "hero_right") {
tokens.subtitleCard.backgroundColor = "rgba(37,99,235,0.82)";
tokens.heroCard.shadow = "0 24px 60px rgba(15,23,42,0.20)";
}
if (params.variant === "split_bottom") {
tokens.featureCard.primary.backgroundColor = "rgba(255,255,255,0.96)";
tokens.featureCard.secondary.backgroundColor = "rgba(255,255,255,0.94)";
tokens.featureCard.neutral.backgroundColor = "rgba(255,255,255,0.92)";
tokens.canvas.sectionGap = 18;
}
if (params.screenType === "A") {
tokens.badge.backgroundColor = "rgba(15,23,42,0.90)";
tokens.compare.afterBackground = "rgba(15,23,42,0.92)";
}
if (params.screenType === "C") {
tokens.cta.buttonBackground = "rgba(124,58,237,0.94)";
tokens.cta.buttonBorder = "rgba(255,255,255,0.28)";
}
if (params.isFirstScreen) {
tokens.typography.title.fontSize = 58;
tokens.heroCard.shadow = "0 28px 70px rgba(15,23,42,0.22)";
}
return tokens;
}

View File

@ -6,6 +6,7 @@ import type {
DetailUiScreenProtocol,
DetailUiScreenReviewPatch,
} from "@/lib/detail-ui-protocol";
import { getDetailUiDesignTokens } from "@/lib/detail-ui-design-tokens";
import type {
UiSurfaceComponent,
UiSurfacePatch,
@ -255,6 +256,11 @@ function buildSectionedTextComponents(params: {
screenIndex: params.screenIndex,
screenType: params.screen.screenType,
});
const tokens = getDetailUiDesignTokens({
variant,
screenType: params.screen.screenType,
isFirstScreen: params.screenIndex === 1,
});
const badgeLabel = buildScreenBadgeLabel({
screenIndex: params.screenIndex,
screenType: params.screen.screenType,
@ -316,14 +322,14 @@ function buildSectionedTextComponents(params: {
content: badgeLabel,
role: "screen_badge",
style: {
fontSize: 16,
fontSize: tokens.typography.label.fontSize + 2,
fontWeight: "700",
color: "#f8fafc",
color: tokens.badge.color,
textAlign: "center",
lineHeight: 1.15,
letterSpacing: 0.4,
backgroundColor: "rgba(15,23,42,0.82)",
borderColor: "rgba(255,255,255,0.18)",
backgroundColor: tokens.badge.backgroundColor,
borderColor: tokens.badge.borderColor,
borderWidth: 1,
borderRadius: 999,
padding: 8,
@ -354,18 +360,18 @@ function buildSectionedTextComponents(params: {
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",
fontSize: Math.max(34, Number(titleBlock?.style.fontSize || tokens.typography.title.fontSize)),
fontWeight: titleBlock?.style.fontWeight || tokens.typography.title.fontWeight,
color: tokens.typography.title.color,
textAlign: "left",
lineHeight: 1.08,
letterSpacing: -0.8,
backgroundColor: "rgba(255,255,255,0.86)",
borderColor: "rgba(255,255,255,0.38)",
lineHeight: tokens.typography.title.lineHeight,
letterSpacing: tokens.typography.title.letterSpacing,
backgroundColor: tokens.heroCard.backgroundColor,
borderColor: tokens.heroCard.borderColor,
borderWidth: 1,
borderRadius: 28,
padding: 24,
boxShadow: "0 22px 58px rgba(15,23,42,0.18)",
boxShadow: tokens.heroCard.shadow,
backdropBlur: 14,
},
metadata: {
@ -392,18 +398,17 @@ function buildSectionedTextComponents(params: {
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",
fontSize: Math.max(22, Number(subtitleBlock?.style.fontSize || tokens.typography.subtitle.fontSize)),
fontWeight: subtitleBlock?.style.fontWeight || tokens.typography.subtitle.fontWeight,
color: tokens.subtitleCard.color,
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)",
lineHeight: tokens.typography.subtitle.lineHeight,
backgroundColor: tokens.subtitleCard.backgroundColor,
borderColor: tokens.subtitleCard.borderColor,
borderWidth: 1,
borderRadius: 22,
padding: 18,
boxShadow: "0 18px 42px rgba(15,23,42,0.18)",
boxShadow: tokens.subtitleCard.shadow,
backdropBlur: 12,
},
metadata: {
@ -477,29 +482,29 @@ function buildSectionedTextComponents(params: {
zIndex: 3,
content: `✦ ${highlight.tag}`,
role: "feature_tag",
style: {
fontSize: 13,
fontWeight: "700",
color:
highlight.accent === "primary"
? "#1d4ed8"
style: {
fontSize: 13,
fontWeight: "700",
color:
highlight.accent === "primary"
? tokens.featureTag.primary.color
: highlight.accent === "secondary"
? "#7c3aed"
: "#334155",
textAlign: "center",
lineHeight: 1.1,
backgroundColor:
highlight.accent === "primary"
? "rgba(219,234,254,0.96)"
? tokens.featureTag.secondary.color
: tokens.featureTag.neutral.color,
textAlign: "center",
lineHeight: 1.1,
backgroundColor:
highlight.accent === "primary"
? tokens.featureTag.primary.backgroundColor
: highlight.accent === "secondary"
? "rgba(237,233,254,0.96)"
: "rgba(241,245,249,0.96)",
borderColor:
highlight.accent === "primary"
? "rgba(59,130,246,0.24)"
? tokens.featureTag.secondary.backgroundColor
: tokens.featureTag.neutral.backgroundColor,
borderColor:
highlight.accent === "primary"
? tokens.featureTag.primary.borderColor
: highlight.accent === "secondary"
? "rgba(124,58,237,0.24)"
: "rgba(148,163,184,0.24)",
? tokens.featureTag.secondary.borderColor
: tokens.featureTag.neutral.borderColor,
borderWidth: 1,
borderRadius: 999,
padding: 6,
@ -526,25 +531,34 @@ function buildSectionedTextComponents(params: {
content: `${index + 1}. ${highlight.title}${highlight.detail ? `\n${highlight.detail}` : ""}`,
role: "support_card",
style: {
fontSize: bodyBlock ? Math.max(18, Number(bodyBlock.style.fontSize || 22)) : 20,
fontSize: bodyBlock ? Math.max(18, Number(bodyBlock.style.fontSize || tokens.typography.body.fontSize)) : 20,
fontWeight: "600",
color: "#111827",
color: tokens.typography.body.color,
textAlign: "left",
lineHeight: 1.34,
lineHeight: tokens.typography.body.lineHeight,
backgroundColor:
variant === "split_bottom"
? "rgba(255,255,255,0.92)"
: "rgba(248,250,252,0.82)",
? tokens.featureCard.primary.backgroundColor
: highlight.accent === "secondary"
? tokens.featureCard.secondary.backgroundColor
: highlight.accent === "primary"
? tokens.featureCard.primary.backgroundColor
: tokens.featureCard.neutral.backgroundColor,
borderColor:
highlight.accent === "primary"
? "rgba(59,130,246,0.42)"
? tokens.featureCard.primary.borderColor
: highlight.accent === "secondary"
? "rgba(124,58,237,0.34)"
: "rgba(148,163,184,0.28)",
? tokens.featureCard.secondary.borderColor
: tokens.featureCard.neutral.borderColor,
borderWidth: 1,
borderRadius: 24,
padding: 20,
boxShadow: "0 18px 42px rgba(15,23,42,0.12)",
boxShadow:
highlight.accent === "primary"
? tokens.featureCard.primary.shadow
: highlight.accent === "secondary"
? tokens.featureCard.secondary.shadow
: tokens.featureCard.neutral.shadow,
backdropBlur: 12,
},
metadata: {
@ -601,13 +615,13 @@ function buildSectionedTextComponents(params: {
content: row.label,
role: "table_label",
style: {
fontSize: 14,
fontWeight: "700",
color: "#334155",
fontSize: tokens.typography.label.fontSize,
fontWeight: tokens.typography.label.fontWeight,
color: tokens.typography.label.color,
textAlign: "center",
lineHeight: 1.15,
backgroundColor: "rgba(241,245,249,0.96)",
borderColor: "rgba(148,163,184,0.22)",
lineHeight: tokens.typography.label.lineHeight,
backgroundColor: tokens.table.labelBackground,
borderColor: tokens.table.labelBorder,
borderWidth: 1,
borderRadius: 14,
padding: 10,
@ -636,15 +650,15 @@ function buildSectionedTextComponents(params: {
style: {
fontSize: 15,
fontWeight: "600",
color: "#0f172a",
color: tokens.typography.title.color,
textAlign: "left",
lineHeight: 1.18,
backgroundColor: "rgba(255,255,255,0.94)",
borderColor: "rgba(59,130,246,0.16)",
backgroundColor: tokens.table.valueBackground,
borderColor: tokens.table.valueBorder,
borderWidth: 1,
borderRadius: 14,
padding: 12,
boxShadow: "0 8px 22px rgba(15,23,42,0.08)",
boxShadow: tokens.table.sectionShadow,
},
metadata: {
screenIndex: params.screenIndex,
@ -676,11 +690,11 @@ function buildSectionedTextComponents(params: {
style: {
fontSize: 17,
fontWeight: "700",
color: "#334155",
color: tokens.compare.beforeColor,
textAlign: "left",
lineHeight: 1.4,
backgroundColor: "rgba(255,255,255,0.92)",
borderColor: "rgba(148,163,184,0.20)",
backgroundColor: tokens.compare.beforeBackground,
borderColor: tokens.compare.beforeBorder,
borderWidth: 1,
borderRadius: 26,
padding: 20,
@ -711,11 +725,11 @@ function buildSectionedTextComponents(params: {
style: {
fontSize: 17,
fontWeight: "800",
color: "#eff6ff",
color: tokens.compare.afterColor,
textAlign: "left",
lineHeight: 1.4,
backgroundColor: "rgba(37,99,235,0.88)",
borderColor: "rgba(255,255,255,0.18)",
backgroundColor: tokens.compare.afterBackground,
borderColor: tokens.compare.afterBorder,
borderWidth: 1,
borderRadius: 26,
padding: 20,
@ -755,11 +769,11 @@ function buildSectionedTextComponents(params: {
style: {
fontSize: 16,
fontWeight: "600",
color: "#1e293b",
color: tokens.proof.color,
textAlign: "left",
lineHeight: 1.22,
backgroundColor: "rgba(255,255,255,0.88)",
borderColor: "rgba(15,23,42,0.10)",
backgroundColor: tokens.proof.backgroundColor,
borderColor: tokens.proof.borderColor,
borderWidth: 1,
borderRadius: 18,
padding: 14,
@ -792,11 +806,11 @@ function buildSectionedTextComponents(params: {
style: {
fontSize: 18,
fontWeight: "800",
color: "#0f172a",
color: tokens.cta.supportColor,
textAlign: "left",
lineHeight: 1.2,
backgroundColor: "rgba(255,255,255,0.88)",
borderColor: "rgba(37,99,235,0.14)",
backgroundColor: tokens.cta.supportBackground,
borderColor: tokens.cta.supportBorder,
borderWidth: 1,
borderRadius: 24,
padding: 18,
@ -827,12 +841,12 @@ function buildSectionedTextComponents(params: {
style: {
fontSize: 19,
fontWeight: "800",
color: "#f8fafc",
color: tokens.cta.buttonColor,
textAlign: "center",
lineHeight: 1.05,
letterSpacing: 0.2,
backgroundColor: "rgba(37,99,235,0.94)",
borderColor: "rgba(255,255,255,0.24)",
backgroundColor: tokens.cta.buttonBackground,
borderColor: tokens.cta.buttonBorder,
borderWidth: 1,
borderRadius: 999,
padding: 18,
@ -864,11 +878,11 @@ function buildSectionedTextComponents(params: {
style: {
fontSize: 15,
fontWeight: "600",
color: "#eff6ff",
color: tokens.cta.bannerColor,
textAlign: "left",
lineHeight: 1.22,
backgroundColor: "rgba(15,23,42,0.74)",
borderColor: "rgba(255,255,255,0.16)",
backgroundColor: tokens.cta.bannerBackground,
borderColor: tokens.cta.bannerBorder,
borderWidth: 1,
borderRadius: 22,
padding: 16,