From be588599062757076219caa1822d5ecf020fd1e0 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 22 Mar 2026 02:16:55 +0800 Subject: [PATCH] feat: add detail overlay design tokens --- .../canvas/DetailSurfaceOverlayRenderer.tsx | 87 ++++++- lib/detail-ui-design-tokens.ts | 244 ++++++++++++++++++ lib/detail-ui-surface-adapter.ts | 168 ++++++------ 3 files changed, 421 insertions(+), 78 deletions(-) create mode 100644 lib/detail-ui-design-tokens.ts diff --git a/components/canvas/DetailSurfaceOverlayRenderer.tsx b/components/canvas/DetailSurfaceOverlayRenderer.tsx index b511a7ea8..f7e1f36c7 100644 --- a/components/canvas/DetailSurfaceOverlayRenderer.tsx +++ b/components/canvas/DetailSurfaceOverlayRenderer.tsx @@ -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 ( +
+
+
+ +
+
+
{headline}
+ {detail ? ( +
+ {detail} +
+ ) : null} +
+
+
+ ); + } + + if (component.type === "detail_feature_tag") { + return ( +
+ + {content} +
+ ); + } + + if (component.type === "detail_compare_before" || component.type === "detail_compare_after") { + const title = lines[0] || ""; + const body = lines.slice(1).join("\n"); + return ( +
+
+ {title} +
+
+
+ {body} +
+
+ ); + } + + if (component.type === "detail_proof_bar") { + return ( +
+ +
{content}
+
+ ); + } + + if (component.type === "detail_cta_button") { + return ( +
+ {content} + +
+ ); + } + + if (component.type === "detail_cta_support" || component.type === "detail_cta_banner") { + return ( +
+
{content}
+ {component.type === "detail_cta_banner" ? : null} +
+ ); + } + + return content; +} + function getOverlayComponentStyle(params: { component: RenderableOverlayComponent; snapshot: DetailUiScreenOverlaySnapshot; @@ -198,7 +283,7 @@ export function DetailSurfaceOverlayRenderer(params: { content, })} > - {content} + {renderOverlayComponentContent(params.overlay!, component)}
); })} diff --git a/lib/detail-ui-design-tokens.ts b/lib/detail-ui-design-tokens.ts new file mode 100644 index 000000000..a5a4e4e3e --- /dev/null +++ b/lib/detail-ui-design-tokens.ts @@ -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; +} diff --git a/lib/detail-ui-surface-adapter.ts b/lib/detail-ui-surface-adapter.ts index 512810230..40ac7f562 100644 --- a/lib/detail-ui-surface-adapter.ts +++ b/lib/detail-ui-surface-adapter.ts @@ -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,