为 html[data-theme='dark'] 添加完整 CSS 变量覆盖块,建立暗黑模式基石。
--color-ink 收窄为'文字/描边跟随主题'语义,深色 UI 元素(卡片/按钮/遮罩/
代码块/页脚)迁到 --color-dark-bg / --color-overlay 独立 token,避免 ink
反色为浅色文字时连带把深色区块变浅(双语义冲突)。
【基础设施】
- globals.css:
* html/body 顶部硬编码 #FFFFFF !important 改 var() 化(保留 !important)
* :root 新增 --color-overlay / --color-overlay-rgb(遮罩/深色叠加层,
永不反色)
* 新增 html[data-theme='dark'] 覆盖块:墨色反色为浅色文字,bg/text/
border 层级反色,link 反色;保留 --color-dark-* / --color-brand-section
* pre 背景 var(--color-ink) → var(--color-dark-bg)
- tailwind.config.js:注册 overlay 色(rgb() 形式,支持 /50 /60 修饰符)
- layout.tsx:head 内联防 FOUC 同步脚本,读 localStorage['novalon-theme']
设置 data-theme(当前无切换 UI,仅作未来挂载点)
【首页试点 + 布局组件】(浅色值完全等价 → 浅色零视觉差异)
- home-content-v15.tsx:7 处 bg-white → bg-bg-primary(narrative/insights/
cases sections 与卡片),2 处 bg-ink → bg-dark-bg(案例卡 bg-dark-bg 不
随反色,保留深色画布)
- header.tsx / mega-dropdown.tsx:bg-white → bg-bg-primary(导航栏/移动
端菜单/下拉面板)
- footer.tsx:bg-[#0A0E14] → bg-dark-bg(语义统一,视觉零差异)
【全局 UI 组件】(深色区块语义统一 + 遮罩固定深色)
- button.tsx:secondary 'bg-ink hover:bg-ink-light' → bg-dark-bg / hover:bg-dark-bg-secondary;
outline 'hover:bg-ink hover:border-ink' → hover:bg-dark-bg / border-dark-bg
- stats-showcase / milestone-timeline:isDark 分支 bg-ink → bg-dark-bg
- metric-card:isDark 分支 bg-ink/50 → bg-overlay/50,hover:bg-ink → hover:bg-overlay
- dialog / alert-dialog:遮罩 bg-ink/60 → bg-overlay/60(永不反色)
- button.test.tsx:断言 bg-ink → bg-dark-bg,注释同步
【验证】
- type-check 0 errors
- lint 0 errors(127 warnings 既有,非本次引入)
- jest 128 suites / 1623 passed(button 断言同步通过)
- 视觉回归先非 update:21 passed(浅色安全门零 diff)+ 1 failed
(仅 theme-dark-main,dark 快照从浅色名义变真正深色 = 语义修复预期)
- update-snapshots:22 passed,仅 theme-dark-main 基线重写
- computed-style 权威验证(一次性脚本,已删除):
dark 7 项断言全过(html/body/hero/导航栏/案例卡→深色 #0A0E14;
body 文字→浅色 #F8FAFC;footer/overlay→保持深色);light 4 项零回归
【未在 Phase 1 范围】(已识别,留待 Phase 2/3 决策)
- service-detail-content-v4.tsx 等营销页 bg-white:Phase 2 批量 token 化
- admin 页面 bg-white + RichTextEditor:admin 边界决策 + Phase 3
- 品牌红 #C41E3A 在深色底对比度复检:Phase 3
113 lines
4.6 KiB
TypeScript
113 lines
4.6 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { Slot } from '@radix-ui/react-slot';
|
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const buttonVariants = cva(
|
|
'group inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all duration-fast ease-standard disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-primary min-h-[44px] min-w-[44px] touch-manipulation relative overflow-hidden select-none',
|
|
{
|
|
variants: {
|
|
variant: {
|
|
primary:
|
|
'bg-brand text-white shadow-sm hover:bg-brand-hover hover:shadow-brand-hover hover:-translate-y-px active:translate-y-0 active:scale-[0.97] active:shadow-sm transition-all duration-fast ease-standard',
|
|
secondary:
|
|
'bg-dark-bg text-white shadow-sm hover:bg-dark-bg-secondary hover:shadow-md hover:-translate-y-px active:translate-y-0 active:scale-[0.97] active:shadow-sm transition-all duration-fast ease-standard',
|
|
outline:
|
|
'border border-border-accent bg-transparent text-text-primary hover:bg-dark-bg hover:text-white hover:border-dark-bg hover:-translate-y-px hover:shadow-md active:translate-y-0 active:scale-[0.97] active:shadow-sm transition-all duration-fast ease-standard',
|
|
ghost:
|
|
'text-text-secondary hover:bg-bg-secondary hover:text-text-primary active:scale-[0.97] transition-all duration-fast ease-standard',
|
|
link:
|
|
'text-text-primary underline-offset-4 hover:text-brand relative after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-0 hover:after:w-full after:bg-brand after:transition-all after:duration-normal after:ease-standard',
|
|
destructive:
|
|
'bg-error text-white shadow-sm hover:bg-error-hover hover:-translate-y-px active:translate-y-0 active:scale-[0.97] transition-all duration-fast ease-standard',
|
|
},
|
|
size: {
|
|
sm: 'h-9 rounded-full px-3 text-xs font-medium',
|
|
default: 'h-11 rounded-full px-5 py-2 font-medium',
|
|
lg: 'h-12 rounded-full px-6 text-base font-semibold',
|
|
xl: 'h-14 rounded-full px-8 text-base font-semibold',
|
|
icon: 'h-11 w-11 rounded-full',
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: 'primary',
|
|
size: 'default',
|
|
},
|
|
}
|
|
);
|
|
|
|
export interface ButtonProps
|
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
VariantProps<typeof buttonVariants> {
|
|
asChild?: boolean;
|
|
showRipple?: boolean;
|
|
}
|
|
|
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
({ className, variant, size, asChild = false, onClick, children, showRipple = true, ...props }, ref) => {
|
|
const [ripples, setRipples] = React.useState<{ x: number; y: number; id: number }[]>([]);
|
|
|
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
if (showRipple && variant !== 'link' && variant !== 'ghost') {
|
|
const button = e.currentTarget;
|
|
const rect = button.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
const id = Date.now();
|
|
|
|
setRipples((prev) => [...prev, { x, y, id }]);
|
|
|
|
setTimeout(() => {
|
|
setRipples((prev) => prev.filter((r) => r.id !== id));
|
|
}, 600);
|
|
}
|
|
|
|
onClick?.(e);
|
|
};
|
|
|
|
// asChild 模式:Slot 要求单个 React 元素子节点,因此跳过 ripple 和包裹 span。
|
|
// 直接把 children 交给 Slot,由 Slot 克隆并注入 className/onClick 等 props。
|
|
if (asChild) {
|
|
return (
|
|
<Slot
|
|
className={cn(buttonVariants({ variant, size, className }))}
|
|
ref={ref}
|
|
onClick={onClick}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</Slot>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
className={cn(buttonVariants({ variant, size, className }))}
|
|
ref={ref}
|
|
onClick={handleClick}
|
|
{...props}
|
|
>
|
|
{ripples.map((ripple) => (
|
|
<span
|
|
key={ripple.id}
|
|
className="pointer-events-none absolute rounded-full bg-white/25 animate-ripple"
|
|
style={{
|
|
left: ripple.x,
|
|
top: ripple.y,
|
|
transform: 'translate(-50%, -50%)',
|
|
}}
|
|
/>
|
|
))}
|
|
<span className="relative z-10 flex items-center justify-center gap-2">
|
|
{children}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
);
|
|
Button.displayName = 'Button';
|
|
|
|
export { Button, buttonVariants };
|