Files
novalon-website/docs/superpowers/plans/2026-04-28-phase1-view-transitions-svg-container-queries.md
张翔 fe6e4b1c54 refactor: P0 - remove testimonial, migrate footer & mobile menu to NAVIGATION_V2
- Remove TestimonialSection from homepage (no customers yet)
- Footer: dark theme, NAVIGATION_V2 + MEGA_DROPDOWN_DATA links
- Mobile Menu: NAVIGATION_V2 with collapsible dropdown support
2026-04-30 22:00:00 +08:00

672 lines
20 KiB
Markdown
Raw Permalink 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.
# 前沿技术升级 Phase 1 实现计划:View Transitions + SVG drawSVG + Container Queries
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 在不改变现有静态导出架构的前提下,引入 View Transitions API 实现跨页面流畅过渡、SVG 路径动画实现品牌标题毛笔书写效果、Container Queries 实现组件级响应式布局。
**架构:** 三项技术均为渐进增强——View Transitions 作为浏览器原生 API 零运行时依赖;SVG drawSVG 基于 GSAP 的 SVG 路径动画插件,仅在品牌标题组件中引入;Container Queries 为纯 CSS 特性,替换现有媒体查询驱动的组件布局。三项改动互不耦合,可独立交付。
**技术栈:** React 19.2 View Transitions API、GSAP 3 + DrawSVGPlugin、CSS Container Queries、Next.js 16 App Router
---
## 文件结构
### 新建文件
| 文件 | 职责 |
|------|------|
| `src/components/ui/view-transition.tsx` | View Transition 封装组件,提供声明式 API |
| `src/components/ui/calligraphy-title.tsx` | 品牌标题 SVG 书写动画组件 |
| `src/components/ui/calligraphy-title.test.tsx` | 品牌标题组件测试 |
| `src/components/ui/view-transition.test.tsx` | View Transition 组件测试 |
| `public/brand/ruixin-zhiyuan.svg` | "睿新致遠" SVG 路径数据 |
### 修改文件
| 文件 | 变更内容 |
|------|---------|
| `src/app/(marketing)/layout.tsx` | 包裹 ViewTransition,实现跨页面过渡 |
| `src/components/sections/hero-section.tsx` | 替换 HeroTitle 为 CalligraphyTitle |
| `src/components/sections/hero-section-atoms.tsx` | 移除旧 HeroTitle,改用 CalligraphyTitle |
| `src/components/ui/animated-card.tsx` | 添加 Container Query 支持 |
| `src/components/sections/products-section.tsx` | 产品卡片容器添加 container-type |
| `src/components/sections/services-section.tsx` | 服务卡片容器添加 container-type |
| `src/app/globals.css` | 添加 View Transition 关键帧、Container Query 样式 |
| `package.json` | 添加 gsap 依赖 |
---
## 任务 1View Transitions API 基础封装
**文件:**
- 创建:`src/components/ui/view-transition.tsx`
- 创建:`src/components/ui/view-transition.test.tsx`
- [ ] **步骤 1:编写失败的测试**
```tsx
// src/components/ui/view-transition.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ViewTransitionWrapper } from './view-transition';
describe('ViewTransitionWrapper', () => {
it('renders children when View Transitions API is not supported', () => {
const originalStartViewTransition = document.startViewTransition;
// @ts-expect-error - testing fallback
document.startViewTransition = undefined;
render(
<ViewTransitionWrapper name="hero-title">
<h1>Test Content</h1>
</ViewTransitionWrapper>
);
expect(screen.getByText('Test Content')).toBeInTheDocument();
// @ts-expect-error - restore
document.startViewTransition = originalStartViewTransition;
});
it('applies view-transition-name style when name prop is provided', () => {
render(
<ViewTransitionWrapper name="hero-title">
<h1>Test Content</h1>
</ViewTransitionWrapper>
);
const element = screen.getByText('Test Content').parentElement;
expect(element?.style.viewTransitionName).toBe('hero-title');
});
it('renders without name prop (no view-transition-name)', () => {
render(
<ViewTransitionWrapper>
<h1>Test Content</h1>
</ViewTransitionWrapper>
);
const element = screen.getByText('Test Content').parentElement;
expect(element?.style.viewTransitionName).toBe('');
});
});
```
- [ ] **步骤 2:运行测试验证失败**
运行:`npx vitest run src/components/ui/view-transition.test.tsx`
预期:FAIL — 模块 `./view-transition` 不存在
- [ ] **步骤 3:编写最少实现代码**
```tsx
// src/components/ui/view-transition.tsx
'use client';
import { type ReactNode, type CSSProperties } from 'react';
interface ViewTransitionWrapperProps {
children: ReactNode;
name?: string;
className?: string;
}
export function ViewTransitionWrapper({
children,
name,
className = '',
}: ViewTransitionWrapperProps) {
const style: CSSProperties = name
? { viewTransitionName: name }
: {};
return (
<div style={style} className={className}>
{children}
</div>
);
}
```
- [ ] **步骤 4:运行测试验证通过**
运行:`npx vitest run src/components/ui/view-transition.test.tsx`
预期:PASS
- [ ] **步骤 5Commit**
```bash
git add src/components/ui/view-transition.tsx src/components/ui/view-transition.test.tsx
git commit -m "feat: add ViewTransitionWrapper component with progressive enhancement"
```
---
## 任务 2View Transitions 跨页面过渡集成
**文件:**
- 修改:`src/app/(marketing)/layout.tsx`
- 修改:`src/app/globals.css`
- [ ] **步骤 1:在 globals.css 添加 View Transition 关键帧**
`src/app/globals.css``@layer base` 块末尾添加:
```css
/* View Transitions - 跨页面过渡动画 */
@keyframes vt-fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes vt-fade-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-8px); }
}
@keyframes vt-hero-enter {
from { opacity: 0; clip-path: circle(0% at 50% 50%); }
to { opacity: 1; clip-path: circle(75% at 50% 50%); }
}
::view-transition-old(root) {
animation: vt-fade-out 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
::view-transition-new(root) {
animation: vt-fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
::view-transition-old(hero-title) {
animation: none;
}
::view-transition-new(hero-title) {
animation: vt-hero-enter 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
}
::view-transition-old(hero-title),
::view-transition-new(hero-title) {
animation: none !important;
}
}
```
- [ ] **步骤 2:修改 MarketingLayout 集成 View Transitions**
`src/app/(marketing)/layout.tsx` 中,将主内容区域包裹 ViewTransitionWrapper
找到 `export default function MarketingLayout` 函数,在 children 外层添加 ViewTransitionWrapper
```tsx
// 在文件顶部添加导入
import { ViewTransitionWrapper } from '@/components/ui/view-transition';
// 在 return 的 children 包裹处,找到所有渲染 children 的位置
// 对主站布局的 children 添加 ViewTransitionWrapper
<ViewTransitionWrapper name="page-content">
{children}
</ViewTransitionWrapper>
// 对产品详情页布局的 children 添加:
<ViewTransitionWrapper name="product-content">
{children}
</ViewTransitionWrapper>
```
- [ ] **步骤 3:在 HeroSection 中标记共享元素**
修改 `src/components/sections/hero-section.tsx`,对品牌标题添加 view-transition-name
找到 HeroTitle 的渲染位置,在其外层包裹:
```tsx
<ViewTransitionWrapper name="hero-title">
<HeroTitle isVisible={isVisible} />
</ViewTransitionWrapper>
```
- [ ] **步骤 4:验证 View Transitions 工作正常**
运行:`npm run dev`
操作:在首页和各子页面之间导航,观察页面过渡动画
预期:页面切换时有淡入淡出效果,品牌标题有圆形展开动画
- [ ] **步骤 5Commit**
```bash
git add src/app/globals.css src/app/\(marketing\)/layout.tsx src/components/sections/hero-section.tsx
git commit -m "feat: integrate View Transitions API for cross-page transitions"
```
---
## 任务 3:GSAP 安装与 SVG 品牌标题路径准备
**文件:**
- 创建:`public/brand/ruixin-zhiyuan.svg`
- [ ] **步骤 1:安装 GSAP**
运行:`npm install gsap`
- [ ] **步骤 2:创建"睿新致遠"SVG 路径文件**
将品牌标题转为 SVG path 数据。由于青柳隷書字体已内嵌,需要用字体渲染后提取路径。
创建 `public/brand/ruixin-zhiyuan.svg`
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 200" fill="none">
<!---->
<path class="stroke-1" d="M80,40 L80,160 M50,60 L110,60 M50,100 L110,100 M40,140 L120,140 M50,40 L50,160 M110,40 L110,160" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!---->
<path class="stroke-2" d="M200,40 L200,160 M170,60 L230,60 M170,100 L230,100 M160,140 L240,140 M170,40 L170,160 M230,40 L230,160 M200,100 L230,140" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!---->
<path class="stroke-3" d="M320,40 L320,160 M290,60 L350,60 M290,100 L350,100 M280,140 L360,140 M290,40 L290,160 M350,40 L350,160" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<!---->
<path class="stroke-4" d="M440,40 L440,160 M410,60 L470,60 M410,100 L470,100 M400,140 L480,140 M410,40 L410,160 M470,40 L470,160 M440,100 L470,140 M400,80 L440,120" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
```
> **注意**:上述 SVG 为占位骨架。实际生产需使用字体转 SVG 工具(如 opentype.js 或在线工具 text-to-svg)从青柳隷書字体提取精确路径。此步骤在实现时需用脚本从 `src/app/fonts/AoyagiReisho.ttf` 提取。
- [ ] **步骤 3:验证 SVG 可访问**
运行:`npm run dev`
访问:`http://localhost:3000/brand/ruixin-zhiyuan.svg`
预期:SVG 文件可正常加载
- [ ] **步骤 4Commit**
```bash
git add package.json package-lock.json public/brand/ruixin-zhiyuan.svg
git commit -m "feat: add GSAP dependency and brand title SVG path data"
```
---
## 任务 4CalligraphyTitle 组件实现
**文件:**
- 创建:`src/components/ui/calligraphy-title.tsx`
- 创建:`src/components/ui/calligraphy-title.test.tsx`
- [ ] **步骤 1:编写失败的测试**
```tsx
// src/components/ui/calligraphy-title.test.tsx
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CalligraphyTitle } from './calligraphy-title';
describe('CalligraphyTitle', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('renders the title text as fallback', () => {
render(<CalligraphyTitle text="睿新致遠" />);
expect(screen.getByText('睿新致遠')).toBeInTheDocument();
});
it('applies brand font family', () => {
render(<CalligraphyTitle text="睿新致遠" />);
const element = screen.getByText('睿新致遠');
expect(element.style.fontFamily).toContain('Aoyagi Reisho');
});
it('respects reduced motion preference', () => {
const matchMediaSpy = vi.spyOn(window, 'matchMedia').mockImplementation(
(query: string) => ({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})
);
render(<CalligraphyTitle text="睿新致遠" />);
const element = screen.getByText('睿新致遠');
expect(element).toHaveStyle({ opacity: '1' });
matchMediaSpy.mockRestore();
});
it('applies custom className', () => {
render(<CalligraphyTitle text="睿新致遠" className="custom-class" />);
const element = screen.getByText('睿新致遠').closest('.custom-class');
expect(element).toBeInTheDocument();
});
});
```
- [ ] **步骤 2:运行测试验证失败**
运行:`npx vitest run src/components/ui/calligraphy-title.test.tsx`
预期:FAIL — 模块 `./calligraphy-title` 不存在
- [ ] **步骤 3:编写最少实现代码**
```tsx
// src/components/ui/calligraphy-title.tsx
'use client';
import { useRef, useEffect, useState } from 'react';
import { gsap } from 'gsap';
import { DrawSVGPlugin } from 'gsap/DrawSVGPlugin';
gsap.registerPlugin(DrawSVGPlugin);
interface CalligraphyTitleProps {
text: string;
className?: string;
duration?: number;
stagger?: number;
delay?: number;
}
export function CalligraphyTitle({
text,
className = '',
duration = 2.5,
stagger = 0.4,
delay = 0.3,
}: CalligraphyTitleProps) {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
useEffect(() => {
if (prefersReducedMotion || !svgRef.current) return;
const paths = svgRef.current.querySelectorAll('path');
if (paths.length === 0) return;
gsap.set(paths, { drawSVG: '0%' });
const ctx = gsap.context(() => {
gsap.fromTo(
paths,
{ drawSVG: '0%' },
{
drawSVG: '100%',
duration,
stagger,
delay,
ease: 'power2.inOut',
}
);
}, svgRef);
return () => ctx.revert();
}, [prefersReducedMotion, duration, stagger, delay]);
return (
<div ref={containerRef} className={`calligraphy-title ${className}`}>
<svg
ref={svgRef}
viewBox="0 0 800 200"
className="w-full h-auto"
aria-hidden="true"
style={{ display: prefersReducedMotion ? 'none' : 'block' }}
>
<use href="/brand/ruixin-zhiyuan.svg#strokes" />
</svg>
<span
className={prefersReducedMotion ? '' : 'sr-only'}
style={{
fontFamily: "var(--font-aoyagi-reisho), 'Aoyagi Reisho', 'Ma Shan Zheng', serif",
fontWeight: 'normal',
}}
>
{text}
</span>
</div>
);
}
```
- [ ] **步骤 4:运行测试验证通过**
运行:`npx vitest run src/components/ui/calligraphy-title.test.tsx`
预期:PASS
- [ ] **步骤 5Commit**
```bash
git add src/components/ui/calligraphy-title.tsx src/components/ui/calligraphy-title.test.tsx
git commit -m "feat: add CalligraphyTitle component with GSAP DrawSVG animation"
```
---
## 任务 5:集成 CalligraphyTitle 到 Hero 区域
**文件:**
- 修改:`src/components/sections/hero-section-atoms.tsx`
- 修改:`src/components/sections/hero-section.tsx`
- [ ] **步骤 1:替换 HeroTitle 组件**
`src/components/sections/hero-section-atoms.tsx` 中,修改 `HeroTitle` 函数:
```tsx
// 替换现有 HeroTitle 实现
import { CalligraphyTitle } from '@/components/ui/calligraphy-title';
export function HeroTitle(_props: HeroContentProps) {
const shouldReduceMotion = useReducedMotion();
if (shouldReduceMotion) {
return (
<h1
id="hero-heading"
className="text-5xl sm:text-6xl lg:text-7xl tracking-tight mb-6"
style={{
fontFamily: "var(--font-aoyagi-reisho), 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif",
fontWeight: 'normal',
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale',
textRendering: 'optimizeLegibility',
}}
>
{COMPANY_INFO.shortName}
</h1>
);
}
return (
<CalligraphyTitle
text={COMPANY_INFO.shortName}
className="text-5xl sm:text-6xl lg:text-7xl tracking-tight mb-6"
duration={2.5}
stagger={0.4}
delay={0.3}
/>
);
}
```
- [ ] **步骤 2:移除旧的 InkReveal 包裹**
`src/components/sections/hero-section-atoms.tsx` 中,移除 `HeroTitle` 中的 `InkReveal` 包裹(因为 CalligraphyTitle 自带动画),确保 h1 标签和 `id="hero-heading"` 保留。
- [ ] **步骤 3:验证 Hero 标题动画效果**
运行:`npm run dev`
访问:`http://localhost:3000`
预期:品牌标题"睿新致遠"以毛笔书写动画逐笔呈现
- [ ] **步骤 4Commit**
```bash
git add src/components/sections/hero-section-atoms.tsx src/components/sections/hero-section.tsx
git commit -m "feat: replace HeroTitle with CalligraphyTitle for brush writing animation"
```
---
## 任务 6Container Queries 实现
**文件:**
- 修改:`src/components/ui/animated-card.tsx`
- 修改:`src/components/sections/products-section.tsx`
- 修改:`src/components/sections/services-section.tsx`
- 修改:`src/app/globals.css`
- [ ] **步骤 1:在 globals.css 添加 Container Query 工具类**
`src/app/globals.css``@layer utilities` 块中添加:
```css
/* Container Queries */
@utility cq-container {
container-type: inline-size;
container-name: card;
}
@utility cq-container-section {
container-type: inline-size;
container-name: section;
}
```
`@layer base` 块之后添加:
```css
/* Container Query 响应式卡片布局 */
@container card (min-width: 350px) {
.cq-card-horizontal {
display: grid;
grid-template-columns: 120px 1fr;
gap: 1rem;
}
}
@container card (min-width: 500px) {
.cq-card-horizontal {
grid-template-columns: 180px 1fr;
gap: 1.5rem;
}
}
@container section (min-width: 768px) {
.cq-section-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@container section (min-width: 1024px) {
.cq-section-grid {
grid-template-columns: repeat(3, 1fr);
}
}
```
- [ ] **步骤 2:修改 AnimatedCard 添加 container-type**
`src/components/ui/animated-card.tsx` 中,找到 `motion.div``className`,添加 `cq-container`
```tsx
// 修改前
className={cn('ink-card', className)}
// 修改后
className={cn('ink-card cq-container', className)}
```
- [ ] **步骤 3:修改 ProductsSection 添加容器查询**
`src/components/sections/products-section.tsx` 中,找到产品卡片的 grid 容器 div,添加 `cq-container-section`
```tsx
// 修改前
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 items-stretch">
// 修改后
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 items-stretch cq-container-section">
```
- [ ] **步骤 4:修改 ServicesSection 添加容器查询**
`src/components/sections/services-section.tsx` 中做同样的修改。
- [ ] **步骤 5:验证 Container Queries 工作**
运行:`npm run dev`
操作:调整浏览器窗口大小,观察卡片在不同容器宽度下的布局变化
预期:卡片在窄容器中单列显示,宽容器中自动切换为多列
- [ ] **步骤 6:运行全量单元测试**
运行:`npx vitest run`
预期:所有测试通过
- [ ] **步骤 7Commit**
```bash
git add src/app/globals.css src/components/ui/animated-card.tsx src/components/sections/products-section.tsx src/components/sections/services-section.tsx
git commit -m "feat: add Container Queries for component-level responsive layouts"
```
---
## 任务 7:Phase 1 集成验证与收尾
**文件:**
- 无新增/修改
- [ ] **步骤 1:运行完整构建**
运行:`npm run build`
预期:构建成功,无错误
- [ ] **步骤 2:运行全量测试**
运行:`npx vitest run`
预期:所有测试通过
- [ ] **步骤 3:运行类型检查**
运行:`npm run type-check`
预期:无类型错误
- [ ] **步骤 4:运行 Lighthouse 审计**
运行:`npm run lighthouse:mobile`
预期:Performance ≥ 90, Accessibility ≥ 95
- [ ] **步骤 5:验证渐进增强降级**
操作:
1. 在不支持 View Transitions 的浏览器(如 Firefox < 126)中访问 → 页面正常显示,无过渡动画
2. 在不支持 Container Queries 的浏览器中 → 卡片使用原有媒体查询布局
3. 开启 `prefers-reduced-motion` → 品牌标题直接显示,无书写动画
预期:所有降级场景均正常工作
- [ ] **步骤 6:最终 Commit**
```bash
git add -A
git commit -m "chore: Phase 1 integration verification complete"
```