Files
novalon-website/src/components/detail/l3-signals-slot.tsx
T
zhangxiang 5ecab7b15d fix(theme): 品牌色令牌语义拆分与暗黑模式全站治理
- 拆分品牌色「文字/底色」双通道:新增 --color-brand-ink(-rgb)(深色 #F87171)
  与 tailwind brand.ink;text-brand → text-brand-ink 迁移 247 处;
  bg-brand/border-brand 保持 --color-brand-rgb 不翻转(红底白字 5.58:1 不能动)
- dark 块补齐 --color-brand-bg: #2A1418(修复深色白字压浅粉底 1.04:1)
- hero 徽章新增 --badge-accent-text 桥接:accentColor 直用作深底文字不可读
  (#1e3a5f 深底 1.65:1),浅色取原色 / 深色 color-mix 提亮
- 硬编码颜色 token 化:bg-white/text-gray-*/border-gray-* → bg-bg-*/text-text-*/border-border-*
- 可访问性:触控目标 <24px 归零(footer 链接列 [&_a]:min-h-6 收口 377 处等)、
  news 搜索框补 aria-label、标题跳级 h2→h4 修正为 h3、装饰 blur 光斑容器补
  overflow-hidden 消除伪文本裁剪
- 验证:tsc 0 / eslint 0 error / jest 0 失败用例 / dogfood 33 路由 × 双主题 P1-P3 全零
2026-09-20 11:50:58 +08:00

183 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
/**
* L3SignalsSlot — L3 信任层「替代信号」槽
*
* 设计意图:
* - 真实数据(客户案例 / 资质认证 / 数据佐证 / 证言)需获得客户授权后才能公开;
* 在那之前,详情页 L3 必须有可验证的兜底内容(避免 L3 整段消失 = 信任真空)。
* - 四类替代信号(方法论透明度 / 团队资历 / 里程碑 / 服务承诺)均为站点内
* 可访问的真实路由(/methodology · /team · /about/brand · /contact),
* 不存在编造。
*
* 接入位置:详情页 L3 区块(案例 / 认证 / 数据)之前,由 L3EmptyFallback 在三者
* 全空时统一包裹渲染;任一非空时本组件不渲染,让真实 section 自行展示。
*
* 设计约束(CLAUDE.md / CONTEXT.md):
* - 品牌红 ≥3 处触达点(eyebrow 短线 + Icon 容器 + 卡片 hover 边)
* - 动效 ≤700ms ease-inkScrollReveal/StaggerReveal 默认 400ms,符合"动效四原则 fast"
* - WCAG AAtext-secondary 在 bg-primary 4.5:1+hover 状态焦点环保留
*
* @see /Users/zhangxiang/Codes/Novalon/novalon-website/.workbuddy/memory/MEMORY.md
* "内容真实性约束"(零编造前提下的 L3 替代信号)
*/
import Link from 'next/link';
import {
ArrowUpRight,
Compass,
Milestone,
ShieldCheck,
Users,
} from 'lucide-react';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { SectionLabel } from '@/components/ui/page-decoration';
import { cn } from '@/lib/utils';
interface L3Signal {
href: string;
eyebrow: string;
title: string;
desc: string;
Icon: React.ComponentType<{ className?: string }>;
/**
* 服务承诺卡:文案散落 5 处但无可复用 L3 section
* 待业务确认后才有具体承诺条款。当前显示「待业务确认」占位(无 href 跳转)。
*/
disabled?: boolean;
}
const SIGNALS: readonly L3Signal[] = [
{
href: '/methodology',
eyebrow: '方法论',
title: '交付方法论',
desc: '从诊断评估到持续优化,四阶段可量化路径;不依赖案例也能证明过程可控。',
Icon: Compass,
},
{
href: '/team',
eyebrow: '团队',
title: '团队资历',
desc: '创始团队与核心成员的背景、承诺宣言与协作方式。',
Icon: Users,
},
{
href: '/about/brand',
eyebrow: '历程',
title: '发展历程',
desc: '2026 年 1 月成立至今的关键节点与里程碑。',
Icon: Milestone,
},
{
href: '/contact',
eyebrow: '承诺',
title: '服务承诺',
desc: '具体承诺条款待业务确认后公开;当前阶段以共创协议为准,欢迎垂询。',
Icon: ShieldCheck,
disabled: true,
},
] as const;
export function L3SignalsSlot({
heading = '在数据之外,我们用透明度建立信任',
description = '客户案例与资质认证需获得授权后才能公开;在那之前,以下四个锚点是我们可验证的承诺。',
}: {
heading?: string;
description?: string;
}) {
return (
<section
aria-labelledby="l3-signals-heading"
className="relative py-16 sm:py-20 md:py-24 overflow-hidden bg-bg-secondary"
>
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<ScrollReveal className="text-center max-w-3xl mx-auto mb-12">
<SectionLabel align="center">信任锚点</SectionLabel>
<h2
id="l3-signals-heading"
className="text-2xl sm:text-3xl md:text-4xl font-bold text-ink tracking-tight leading-tight"
>
{heading}
</h2>
<p className="mt-4 text-base text-text-secondary leading-relaxed">{description}</p>
</ScrollReveal>
{/* grid 类直接挂在 StaggerReveal 上:StaggerReveal 会为每个子项包一层 motion.div
若把 grid 放在外层容器,包装层会成为 grid 的唯一子项,卡片将堆成单列 */}
<StaggerReveal
staggerDelay={0.06}
delayChildren={0.05}
className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6"
>
{SIGNALS.map((s) => (
<SignalCard key={s.eyebrow} signal={s} />
))}
</StaggerReveal>
</div>
</section>
);
}
function SignalCard({ signal }: { signal: L3Signal }) {
const { href, eyebrow, title, desc, Icon, disabled } = signal;
const cardBody = (
<div
className={cn(
'group relative h-full p-6 sm:p-8 rounded-lg border border-border-primary bg-bg-primary',
'transition-all duration-300 ease-out',
disabled
? 'opacity-70 cursor-not-allowed'
: 'hover:border-brand/40 hover:shadow-md hover:-translate-y-1'
)}
>
<span className="inline-block text-[11px] tracking-[0.15em] uppercase font-bold text-brand-ink mb-4">
{eyebrow}
</span>
<div
className="w-10 h-10 mb-4 flex items-center justify-center rounded-md bg-brand-soft text-brand-ink"
aria-hidden="true"
>
<Icon className="w-5 h-5" />
</div>
<h3 className="text-base font-bold text-ink mb-2 flex items-center gap-1.5">
{title}
{!disabled && (
<ArrowUpRight
className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
aria-hidden="true"
/>
)}
</h3>
<p className="text-sm text-text-secondary leading-relaxed">{desc}</p>
{disabled && (
<p className="mt-4 text-xs text-text-muted italic">待业务确认</p>
)}
</div>
);
if (disabled) {
return (
<div aria-disabled="true" className="block rounded-lg">
{cardBody}
</div>
);
}
return (
<Link
href={href}
className="block rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-secondary"
>
{cardBody}
</Link>
);
}