feat(theme): 暗黑模式 Phase 1 基础设施 + 首页试点

为 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
This commit is contained in:
2026-08-31 18:15:58 +08:00
parent 7f83ef68f7
commit 40c6cd612d
15 changed files with 98 additions and 26 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+9 -9
View File
@@ -333,7 +333,7 @@ function TrustSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
</p>
<div className="grid sm:grid-cols-3 gap-4 mb-8">
{earlyStatus.map((s) => (
<div key={s.label} className="p-4 bg-white border border-brand/10">
<div key={s.label} className="p-4 bg-bg-primary border border-brand/10">
<div className="text-sm font-semibold text-brand mb-1">{s.label}</div>
<div className="text-xs text-text-secondary leading-relaxed">{s.desc}</div>
</div>
@@ -389,7 +389,7 @@ function NarrativeSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
}>;
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white" data-testid="narrative-section">
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-bg-primary" data-testid="narrative-section">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="mb-16 sm:mb-20">
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
@@ -400,7 +400,7 @@ function NarrativeSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
<div className="grid md:grid-cols-3 gap-px bg-border-primary">
{NARRATIVE_ACTS_CMS.map((act) => (
<StaggerReveal key={act.number} staggerDelay={0.08}>
<div className="group relative h-full bg-white p-8 sm:p-10 md:p-12 transition-colors duration-300 hover:bg-bg-secondary">
<div className="group relative h-full bg-bg-primary p-8 sm:p-10 md:p-12 transition-colors duration-300 hover:bg-bg-secondary">
<div className="flex items-start justify-between mb-8">
<span
aria-hidden="true"
@@ -447,7 +447,7 @@ function InsightsSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
}>;
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white" data-testid="insights-section">
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-bg-primary" data-testid="insights-section">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-8 mb-16 sm:mb-20">
<ScrollReveal className="md:max-w-3xl">
@@ -480,7 +480,7 @@ function InsightsSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
<StaggerReveal key={i} staggerDelay={0.07}>
<a
href={insight.href}
className="group flex flex-col h-full bg-white p-8 sm:p-10 md:p-12 transition-colors duration-300 hover:bg-bg-secondary"
className="group flex flex-col h-full bg-bg-primary p-8 sm:p-10 md:p-12 transition-colors duration-300 hover:bg-bg-secondary"
>
<div className="flex items-center justify-between mb-8">
<span className="text-[13px] tracking-[0.12em] font-semibold text-brand">
@@ -645,7 +645,7 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
if (CASE_ITEMS.length === 0) {
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white">
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<ScrollReveal>
@@ -670,7 +670,7 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
</StaticLink>
</ScrollReveal>
<ScrollReveal delay={0.15}>
<div className="relative overflow-hidden rounded-[2rem] bg-ink p-10 sm:p-12 h-full flex flex-col shadow-card">
<div className="relative overflow-hidden rounded-[2rem] bg-dark-bg p-10 sm:p-12 h-full flex flex-col shadow-card">
{/* 背景纹理:极淡白色点阵(对标埃森哲深色 spotlight 画布) */}
<div
className="absolute inset-0 pointer-events-none opacity-[0.05]"
@@ -722,7 +722,7 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
const spotlightTestimonial = spotlightCase?.testimonial as Record<string, any> | undefined;
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white">
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-8 mb-16 sm:mb-20">
<ScrollReveal className="md:max-w-3xl">
@@ -746,7 +746,7 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
<ScrollReveal className="mb-6">
<a
href={spotlightCase.href || `/cases/${spotlightCase.slug || ''}`}
className="group relative block overflow-hidden rounded-[2rem] bg-ink p-10 sm:p-14 h-full flex flex-col shadow-card"
className="group relative block overflow-hidden rounded-[2rem] bg-dark-bg p-10 sm:p-14 h-full flex flex-col shadow-card"
>
{/* 背景纹理:极淡白色点阵 */}
<div
+68 -4
View File
@@ -3,12 +3,12 @@
@tailwind utilities;
html {
background-color: #FFFFFF !important;
background-color: var(--color-bg-primary) !important;
}
body {
background-color: #FFFFFF !important;
color: #0A0E14 !important;
background-color: var(--color-bg-primary) !important;
color: var(--color-text-primary) !important;
}
:root {
@@ -130,6 +130,11 @@ body {
--color-dark-text-muted: #94A3B8;
--color-dark-border: #1E293B;
/* 遮罩 / 深色叠加层(dialog 背板、metric-card 深色半透明底):
语义上"永远是深色覆盖",不随暗黑模式反色 —— 故用独立 token。 */
--color-overlay: #0A0E14;
--color-overlay-rgb: 10 14 20;
/* 阴影系统(按层级 + 用途分类) */
/* 基础层级:几乎看不见,用于微妙的边界定义 */
--shadow-xs: 0 1px 1px rgba(10, 14, 20, 0.03);
@@ -364,6 +369,65 @@ body {
--grid-gap-2xl: 3rem;
}
/* === 暗黑模式(html[data-theme='dark']===
策略:仅覆盖 CSS 变量,组件无需逐元素反色。
- 文字墨色 --color-ink 反色为浅色(其语义收窄为"文字/描边跟随主题"
- 页面底色 --color-bg-* 反色为深色
- 深色区块(--color-dark-* / --color-brand-section / --color-overlay / footer
使用独立 token,不随反色 —— 由组件显式引用(如 bg-dark-bg */
html[data-theme='dark'] {
/* 墨色反色 → 文字浅色 */
--color-ink: #F8FAFC;
--color-ink-light: #E2E8F0;
--color-ink-lighter: #CBD5E1;
--color-ink-rgb: 248 250 252;
/* 页面底色反色 */
--color-bg-primary: #0A0E14;
--color-bg-primary-rgb: 10 14 20;
--color-bg-secondary: #0F1419;
--color-bg-secondary-rgb: 15 20 25;
--color-bg-tertiary: #151B23;
--color-bg-section: #0F1419;
--color-bg-hover: #151B23;
--color-bg-elevated: #151B23;
/* 文字层级反色 */
--color-text-primary: #F8FAFC;
--color-text-secondary: #CBD5E1;
--color-text-secondary-rgb: 203 213 225;
--color-text-tertiary: #94A3B8;
--color-text-muted: #94A3B8;
--color-text-muted-rgb: 148 163 184;
--color-text-subtle: #94A3B8;
--color-text-placeholder: #94A3B8;
--color-text-hint: #94A3B8;
--color-text-inverse: #0A0E14;
/* 边框层级反色 */
--color-border-primary: #1E293B;
--color-border-primary-rgb: 30 41 59;
--color-border-secondary: #334155;
--color-border-secondary-rgb: 51 65 85;
--color-border-accent: #F8FAFC;
--color-border-accent-rgb: 248 250 252;
--color-border-light: #1E293B;
--color-border-light-rgb: 30 41 59;
--color-border-dark: #CBD5E1;
--color-border-dark-rgb: 203 213 225;
/* 链接反色 */
--color-link: #F8FAFC;
--color-link-hover: #E04A68;
}
/* html/body 背景跟随主题 token(覆盖顶部 !important 硬编码) */
html[data-theme='dark'],
html[data-theme='dark'] body {
background-color: var(--color-bg-primary) !important;
color: var(--color-text-primary) !important;
}
@layer base {
* {
@apply antialiased;
@@ -541,7 +605,7 @@ body {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: var(--line-height-snug);
background: var(--color-ink);
background: var(--color-dark-bg);
color: var(--color-dark-text-primary);
padding: 1.5em;
border-radius: var(--radius-lg);
+7
View File
@@ -170,6 +170,13 @@ export default async function RootLayout({
suppressHydrationWarning
>
<head>
{/* 主题防 FOUC(暗黑模式 Phase 1 基础设施):首帧前同步读取 localStorage 设置 data-theme
无存储值时保持浅色(默认)。当前无切换 UI,仅作挂载点,未来切换器写入 novalon-theme 即可生效。 */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem('novalon-theme');if(t==='dark'||t==='light'){document.documentElement.setAttribute('data-theme',t);}}catch(e){}})();`,
}}
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
+1 -1
View File
@@ -15,7 +15,7 @@ export function Footer() {
.flatMap(group => group.items);
return (
<footer className="bg-[#0A0E14] text-white relative overflow-hidden" data-testid="footer" role="contentinfo">
<footer className="bg-dark-bg text-white relative overflow-hidden" data-testid="footer" role="contentinfo">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-12 gap-10 lg:gap-12 py-16 lg:py-20">
<div data-testid="card-brand" className="col-span-2 lg:col-span-4">
+2 -2
View File
@@ -86,7 +86,7 @@ function HeaderContent() {
height: isScrolled ? 64 : 80,
}}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className="fixed top-0 left-0 right-0 z-50 transition-colors duration-300 bg-white border-b border-border-primary"
className="fixed top-0 left-0 right-0 z-50 transition-colors duration-300 bg-bg-primary border-b border-border-primary"
>
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<div className="flex items-center justify-between h-16 md:h-full">
@@ -202,7 +202,7 @@ function HeaderContent() {
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
className="absolute top-16 right-0 bottom-0 left-0 bg-white/98 backdrop-blur-xl overflow-y-auto"
className="absolute top-16 right-0 bottom-0 left-0 bg-bg-primary/98 backdrop-blur-xl overflow-y-auto"
id="mobile-menu"
role="navigation"
aria-label="移动端导航"
+1 -1
View File
@@ -76,7 +76,7 @@ export function MegaDropdown({ label, groups, isOpen, isActive, onToggle, onOpen
transition={{ duration: 0.15 }}
className="absolute top-full left-1/2 -translate-x-1/2 pt-2 w-[640px] z-50"
>
<div className="bg-white backdrop-blur-xl rounded-none border border-border-primary p-6">
<div className="bg-bg-primary backdrop-blur-xl rounded-none border border-border-primary p-6">
{groups.map((group, groupIndex) => (
<div key={group.id} className={groupIndex > 0 ? 'mt-6 pt-6 border-t border-border-primary' : ''}>
<div className="flex items-center gap-2 mb-3">
+1 -1
View File
@@ -21,7 +21,7 @@ const AlertDialogOverlay = React.forwardRef<
ref={ref}
data-slot="alert-dialog-overlay"
className={cn(
'fixed inset-0 z-50 bg-ink/60 backdrop-blur-sm',
'fixed inset-0 z-50 bg-overlay/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
+2 -2
View File
@@ -28,8 +28,8 @@ describe('Button Component', () => {
it('should apply secondary variant styles', () => {
render(<Button variant="secondary">Secondary</Button>);
const button = screen.getByRole('button');
// secondary variant 使用 bg-ink
expect(button).toHaveClass('bg-ink');
// secondary variant 使用 bg-dark-bg(深色区块 token,不随暗黑模式反色)
expect(button).toHaveClass('bg-dark-bg');
});
it('should apply outline variant styles', () => {
+2 -2
View File
@@ -13,9 +13,9 @@ const buttonVariants = cva(
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-ink text-white shadow-sm hover:bg-ink-light hover:shadow-md hover:-translate-y-px active:translate-y-0 active:scale-[0.97] active:shadow-sm transition-all duration-fast ease-standard',
'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-ink hover:text-white hover:border-ink hover:-translate-y-px hover:shadow-md active:translate-y-0 active:scale-[0.97] active:shadow-sm transition-all duration-fast ease-standard',
'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:
+1 -1
View File
@@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 z-50 bg-ink/60 backdrop-blur-sm',
'fixed inset-0 z-50 bg-overlay/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
+1 -1
View File
@@ -66,7 +66,7 @@ export function MetricCard({
className={cn(
'group relative overflow-hidden rounded-sm border p-6 sm:p-7 transition-all duration-300 ease-out',
isDark
? 'bg-ink/50 border-white/[0.08] hover:border-white/20 hover:bg-ink'
? 'bg-overlay/50 border-white/[0.08] hover:border-white/20 hover:bg-overlay'
: 'border-border-primary bg-bg-primary hover:bg-bg-secondary',
className
)}
+1 -1
View File
@@ -64,7 +64,7 @@ export function MilestoneTimeline({
>
<div className={cn(
'absolute left-2 top-1 w-5 h-5 rounded-full border-2 flex items-center justify-center',
isDark ? 'bg-ink border-brand' : 'bg-bg-primary border-brand'
isDark ? 'bg-dark-bg border-brand' : 'bg-bg-primary border-brand'
)}>
<div className="w-2 h-2 rounded-full bg-brand" />
</div>
+1 -1
View File
@@ -73,7 +73,7 @@ export function StatsShowcase({
key={i}
className={cn(
'relative p-8 sm:p-10 md:p-12 text-center transition-all duration-500 group',
isDark ? 'bg-ink hover:bg-ink-light' : 'bg-bg-primary hover:bg-bg-hover'
isDark ? 'bg-dark-bg hover:bg-dark-bg-secondary' : 'bg-bg-primary hover:bg-bg-hover'
)}
>
<div className="relative z-10">
+1
View File
@@ -109,6 +109,7 @@ module.exports = {
'text-muted': 'var(--color-dark-text-muted)',
border: 'var(--color-dark-border)',
},
overlay: 'rgb(var(--color-overlay-rgb) / <alpha-value>)',
},
fontFamily: {
sans: ['var(--font-sans)'],