launch huashu-design v1.0: 9 demos + marketing-first README

- 新增 9 个能力 demo(workflow + capability),每个 MP4 + GIF + 60fps
- README 重写:营销 hook 开头 · 功能优先结构 · 自然嵌入 GIF
- 修复 render-video.js Google Fonts networkidle 超时
- 修复 c1-ios-prototype 刘海/Home Indicator 背景色
- 补充 references/editable-pptx.md 和 scripts/export_deck_stage_pdf.mjs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
alchainandClaude Opus 4.7 committed 2026-04-20 18:19:48 +08:00
1 parent f3692aac10
commit b127f8baa3
24 files changed
+8206 -281

No files matched your search

+721
View File
@@ -0,0 +1,721 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · iOS App Prototype</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400&family=Noto+Serif+SC:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [a, b] = input, [x, y] = output;
if (t <= a) return x; if (t >= b) return y;
let p = (t - a) / (b - a); if (easing) p = easing(p);
return x + (y - x) * p;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
const update = () => {
const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
setScale(s);
};
update(); window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false, last = null;
function tick(now) {
if (cancelled) return;
if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
const delta = (now - last) / 1000; last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
{children}
</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
return (
<SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const PAPER = '#FDFBF5';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const OLIVE = '#6a6b4e';
const ASH = '#6b6b6b';
const LINE = '#e5ddcd';
const LINE2 = '#d9d2c5';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── Art image: CSS-rendered "oil painting" hero ──────────
function ArtBlock({ mood = 'warm', height = 200 }) {
// Three curated palettes for variety (no Unsplash dep, stable offline)
const palettes = {
warm: ['#8b4a2b', '#c67b4a', '#e3a876', '#f2d4a7'], // Turner sunset
quiet: ['#3d4a3a', '#6a8066', '#a8b89c', '#e0d8b8'], // Corot pastoral
study: ['#2a3552', '#5e6b8a', '#8b98b5', '#d4c9a5'], // Vermeer indoor
};
const p = palettes[mood];
return (
<div style={{
width: '100%', height, position: 'relative', overflow: 'hidden',
background: `linear-gradient(135deg, ${p[0]} 0%, ${p[1]} 35%, ${p[2]} 70%, ${p[3]} 100%)`,
}}>
{/* Impressionist brush texture */}
<div style={{
position: 'absolute', inset: 0,
background: `
radial-gradient(ellipse 80px 30px at 30% 40%, ${p[3]}44, transparent 70%),
radial-gradient(ellipse 60px 20px at 70% 60%, ${p[0]}33, transparent 70%),
radial-gradient(ellipse 100px 40px at 50% 80%, ${p[2]}44, transparent 70%),
radial-gradient(ellipse 50px 25px at 20% 70%, ${p[1]}55, transparent 70%)
`,
filter: 'blur(1px)',
}} />
{/* Subtle scratch noise */}
<svg width="100%" height="100%" style={{position:'absolute', inset:0, opacity: 0.18}}>
<filter id="paint-noise">
<feTurbulence baseFrequency="0.9" numOctaves="2" />
<feColorMatrix values="0 0 0 0 0.3 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 1 0" />
</filter>
<rect width="100%" height="100%" filter="url(#paint-noise)" />
</svg>
</div>
);
}
// ── iOS Frame (simplified from ios_frame.jsx, positioned for demo) ──
function IosFrame({ children, time = '9:41', scale = 1, style = {} }) {
const W = 420, H = 900;
return (
<div style={{
display: 'inline-block',
padding: 13,
background: '#0a0a0a',
borderRadius: 62,
boxShadow: '0 0 0 2px #2a2a2a, 0 30px 80px rgba(0,0,0,0.35), 0 10px 30px rgba(0,0,0,0.2)',
position: 'relative',
transform: `scale(${scale})`,
transformOrigin: 'center center',
...style,
}}>
<div style={{
position: 'relative', width: W, height: H,
borderRadius: 50, overflow: 'hidden', background: PAPER,
}}>
{/* Status bar */}
<div style={{
position: 'absolute', top: 0, left: 0, right: 0, height: 54,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 34px', fontSize: 17, fontWeight: 600,
fontFamily: '-apple-system, "SF Pro Text", sans-serif',
color: '#000', zIndex: 20, pointerEvents: 'none',
}}>
<span>{time}</span>
<div style={{display:'flex', alignItems:'center', gap: 6}}>
<div style={{display:'flex', alignItems:'flex-end', gap: 2, height: 12}}>
{[4, 6, 9, 11].map((h, i) => <div key={i} style={{width:3, height:h, background:'#000', borderRadius:1}} />)}
</div>
<svg width="16" height="12" viewBox="0 0 16 12">
<path d="M8 11.5a1 1 0 100-2 1 1 0 000 2z" fill="#000" />
<path d="M3 7.5a7 7 0 0110 0" stroke="#000" strokeWidth="1.3" fill="none" strokeLinecap="round" />
<path d="M1 4.5a11 11 0 0114 0" stroke="#000" strokeWidth="1.3" fill="none" strokeLinecap="round" opacity="0.7" />
</svg>
<div style={{width:26, height:12, border:'1.5px solid #000', borderRadius:3, padding:1, position:'relative'}}>
<div style={{width:'85%', height:'100%', background:'#000', borderRadius:1}} />
<div style={{position:'absolute', top:3, right:-3, width:2, height:6, background:'#000', borderRadius:'0 1px 1px 0'}} />
</div>
</div>
</div>
{/* Dynamic island */}
<div style={{
position: 'absolute', top: 12, left: '50%',
transform: 'translateX(-50%)', width: 124, height: 36,
background: '#000', borderRadius: 999, zIndex: 30,
}} />
{/* Content */}
<div style={{position:'absolute', top: 54, left: 0, right: 0, bottom: 34, overflow: 'hidden'}}>
{children}
</div>
{/* Home indicator */}
<div style={{
position: 'absolute', bottom: 10, left: '50%',
transform: 'translateX(-50%)', width: 140, height: 5,
background: 'rgba(0,0,0,0.28)', borderRadius: 999, zIndex: 10,
}} />
</div>
</div>
);
}
// ── Screen: Today ────────────────────────────────────────
function TodayScreen({ animateT = 1 }) {
const headerOp = interpolate(animateT, [0, 0.25], [0, 1]);
const headerY = interpolate(animateT, [0, 0.35], [20, 0], Easing.easeOut);
const heroOp = interpolate(animateT, [0.15, 0.5], [0, 1]);
const heroY = interpolate(animateT, [0.15, 0.5], [30, 0], Easing.easeOut);
const memoriesOp = interpolate(animateT, [0.4, 0.8], [0, 1]);
return (
<div style={{padding: '24px 22px 0', height: '100%', display:'flex', flexDirection:'column', background: PAPER}}>
{/* Header */}
<div style={{opacity: headerOp, transform: `translateY(${headerY}px)`, marginBottom: 18}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
周二 · 4月20日
</div>
<div style={{fontFamily: serif, fontSize: 34, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
今日
</div>
</div>
{/* Hero card */}
<div style={{opacity: heroOp, transform: `translateY(${heroY}px)`, border: `1px solid ${LINE}`, background:'#fff', marginBottom: 14}}>
<ArtBlock mood="warm" height={180} />
<div style={{padding: '14px 16px 16px'}}>
<div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 6}}>
继续阅读 · 剩 12 分钟
</div>
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500, color: INK, lineHeight: 1.2, marginBottom: 4, letterSpacing:'-0.005em'}}>
《<span style={{fontStyle:'italic'}}>沉思录</span>》
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13, color: ASH}}>
马可·奥勒留 · 第四卷
</div>
{/* AI insight */}
<div style={{marginTop: 14, paddingTop: 12, borderTop: `1px solid ${LINE}`,
display:'flex', gap: 10, alignItems: 'flex-start'}}>
<div style={{width: 6, height: 6, borderRadius:'50%', background: TERRA, marginTop: 6, flexShrink: 0}} />
<div>
<div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 2}}>
流明 · 已关联
</div>
<div style={{fontFamily: serif, fontSize: 13, color: INK, lineHeight: 1.4}}>
呼应你 3 周前读的《<span style={{fontStyle:'italic'}}>塞涅卡书简·28</span>》——同在谈<span style={{fontStyle:'italic'}}>内心堡垒</span>。
</div>
</div>
</div>
</div>
</div>
{/* Memory bubbles list */}
<div style={{opacity: memoriesOp, flex: 1}}>
<div style={{fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.25em', marginBottom: 10}}>
来自你的记忆
</div>
{[
{ title: '"Amor fati"——一个说法', sub: '尼采 · 2 个月前', dot: OLIVE },
{ title: '论「注意力即爱」', sub: '薇依 · 5 个月前', dot: TERRA },
].map((m, i) => (
<div key={i} style={{padding: '11px 0', borderTop: i === 0 ? `1px solid ${LINE}` : 'none', borderBottom: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 12}}>
<div style={{width: 8, height: 8, borderRadius: '50%', background: m.dot}} />
<div style={{flex: 1}}>
<div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.3}}>{m.title}</div>
<div style={{fontFamily: mono, fontSize: 9, color: ASH, marginTop: 2, letterSpacing:'0.1em'}}>{m.sub}</div>
</div>
<div style={{fontFamily: serif, fontSize: 18, color: ASH, fontStyle:'italic'}}>→</div>
</div>
))}
</div>
</div>
);
}
// ── Screen: Memory (graph view) ───────────────────────────
function MemoryScreen({ animateT = 1 }) {
const headerOp = interpolate(animateT, [0, 0.3], [0, 1]);
const graphOp = interpolate(animateT, [0.15, 0.6], [0, 1]);
const listOp = interpolate(animateT, [0.5, 0.9], [0, 1]);
// Nodes for graph
const nodes = [
{ x: 210, y: 100, r: 22, label: '斯多葛', emph: true },
{ x: 110, y: 180, r: 14, label: '伦理' },
{ x: 310, y: 170, r: 16, label: '美德', emph: true },
{ x: 90, y: 260, r: 10, label: '' },
{ x: 200, y: 240, r: 12, label: '' },
{ x: 320, y: 270, r: 18, label: '自我' },
{ x: 150, y: 330, r: 11, label: '' },
{ x: 280, y: 340, r: 13, label: '心流' },
];
const edges = [
[0, 1], [0, 2], [0, 4], [1, 3], [2, 5], [4, 5], [4, 6], [5, 7], [6, 7], [1, 4],
];
return (
<div style={{padding: '24px 22px 0', height: '100%', background: PAPER, display:'flex', flexDirection:'column'}}>
<div style={{opacity: headerOp, marginBottom: 14}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
287 条 · 4 个聚类
</div>
<div style={{fontFamily: serif, fontSize: 34, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
记忆
</div>
</div>
{/* Graph visualization */}
<div style={{
opacity: graphOp, border:`1px solid ${LINE}`, background:'#fff',
height: 400, position:'relative', overflow:'hidden', marginBottom: 14,
}}>
<svg viewBox="0 0 420 400" width="100%" height="100%" style={{display:'block'}}>
{/* edges */}
{edges.map(([a, b], i) => {
const na = nodes[a], nb = nodes[b];
return <line key={i} x1={na.x} y1={na.y} x2={nb.x} y2={nb.y}
stroke="#c8beb0" strokeWidth={0.8} opacity={0.7} />;
})}
{/* nodes */}
{nodes.map((n, i) => {
const appear = interpolate(animateT, [0.2 + i * 0.04, 0.4 + i * 0.04], [0, 1], Easing.easeOut);
return (
<g key={i} opacity={appear}>
<circle cx={n.x} cy={n.y} r={n.r}
fill={n.emph ? TERRA : '#ede5d3'}
stroke={n.emph ? TERRA : '#b8ac94'}
strokeWidth={1} />
{n.label && (
<text x={n.x} y={n.y + n.r + 14} textAnchor="middle"
fontFamily={serif} fontStyle="italic" fontSize={11}
fill={n.emph ? TERRA : '#666'}>
{n.label}
</text>
)}
</g>
);
})}
</svg>
{/* corner label */}
<div style={{position:'absolute', top: 12, left: 14,
fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.2em'}}>
· 图谱视图
</div>
<div style={{position:'absolute', bottom: 12, right: 14,
fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em'}}>
斯多葛派 · 47 条
</div>
</div>
{/* Top clusters */}
<div style={{opacity: listOp}}>
<div style={{fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.25em', marginBottom: 8}}>
主要聚类
</div>
{[
{ name: '斯多葛', count: 47, swatch: TERRA },
{ name: '注意力', count: 32, swatch: OLIVE },
].map((c, i) => (
<div key={i} style={{padding: '9px 0', borderTop: i === 0 ? `1px solid ${LINE}` : 'none', borderBottom: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 10}}>
<div style={{width: 14, height: 14, background: c.swatch, borderRadius: 2}} />
<div style={{flex: 1, fontFamily: serif, fontSize: 14, color: INK}}>{c.name}</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH}}>{c.count}</div>
</div>
))}
</div>
</div>
);
}
// ── Screen: Chat ─────────────────────────────────────────
function ChatScreen({ animateT = 1 }) {
const headerOp = interpolate(animateT, [0, 0.2], [0, 1]);
const msg1Op = interpolate(animateT, [0.15, 0.35], [0, 1]);
const msg2Op = interpolate(animateT, [0.4, 0.65], [0, 1]);
const ctxCardOp = interpolate(animateT, [0.55, 0.75], [0, 1]);
const msg3Op = interpolate(animateT, [0.7, 0.92], [0, 1]);
const inputOp = interpolate(animateT, [0.5, 0.8], [0, 1]);
// Typewriter for AI reply (msg2)
const aiText = '两处呼应——马可谈的「内心堡垒」和塞涅卡第 28 封信中的独处。';
const charCount = Math.floor(interpolate(animateT, [0.4, 0.7], [0, aiText.length]));
const typed = aiText.slice(0, charCount);
return (
<div style={{padding: '24px 22px 0', height: '100%', background: PAPER, display:'flex', flexDirection:'column'}}>
<div style={{opacity: headerOp, marginBottom: 16}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
流明 · 已关联
</div>
<div style={{fontFamily: serif, fontSize: 30, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
问你的记忆
</div>
</div>
<div style={{flex: 1, display:'flex', flexDirection:'column', gap: 12}}>
{/* User msg */}
<div style={{opacity: msg1Op, alignSelf:'flex-end', maxWidth: '85%',
background:'#f0e8d5', padding: '10px 14px', borderRadius: '18px 18px 4px 18px'}}>
<div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.4}}>
我最近关于<span style={{fontStyle:'italic'}}>「独处」</span>在想什么?
</div>
</div>
{/* AI reply (typewriter) */}
<div style={{opacity: msg2Op, alignSelf:'flex-start', maxWidth: '90%',
paddingLeft: 14, borderLeft: `2px solid ${TERRA}`}}>
<div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 4}}>
流明
</div>
<div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.45, minHeight: 60}}>
{typed}
{charCount < aiText.length && charCount > 0 && (
<span style={{color: TERRA, marginLeft: 2}}>|</span>
)}
</div>
</div>
{/* Context card */}
<div style={{opacity: ctxCardOp, alignSelf:'flex-start', maxWidth: '88%',
background:'#fff', border: `1px solid ${LINE}`, padding: '10px 12px',
marginLeft: 14, display:'flex', gap: 10, alignItems:'center'}}>
<div style={{width: 40, height: 40, flexShrink: 0, overflow:'hidden'}}>
<ArtBlock mood="study" height={40} />
</div>
<div style={{flex: 1}}>
<div style={{fontFamily: serif, fontSize: 12, fontWeight: 500, color: INK, lineHeight: 1.2}}>
塞涅卡 · 第 28 封信
</div>
<div style={{fontFamily: mono, fontSize: 8, color: ASH, marginTop: 2, letterSpacing:'0.15em'}}>
3 周前阅读 · 4 分钟
</div>
</div>
<div style={{fontFamily: serif, fontSize: 16, color: ASH, fontStyle:'italic'}}>↗</div>
</div>
{/* User follow-up */}
<div style={{opacity: msg3Op, alignSelf:'flex-end', maxWidth: '70%',
background:'#f0e8d5', padding: '10px 14px', borderRadius: '18px 18px 4px 18px'}}>
<div style={{fontFamily: serif, fontSize: 14, color: INK}}>
给我看原文段落。
</div>
</div>
</div>
{/* Input bar */}
<div style={{opacity: inputOp, padding: '10px 0 16px',
borderTop: `1px solid ${LINE}`, marginTop: 12,
display:'flex', alignItems:'center', gap: 10}}>
<div style={{flex: 1, fontFamily: serif, fontStyle:'italic',
fontSize: 13, color: ASH}}>
从你的阅读里问我任何事…
</div>
<div style={{width: 28, height: 28, background: TERRA, borderRadius: '50%',
display:'flex', alignItems:'center', justifyContent:'center',
color:'#fff', fontFamily: sans, fontSize: 16}}>↑</div>
</div>
</div>
);
}
// ── Tab bar ───────────────────────────────────────────────
function TabBar({ active = 'today', tapping = null }) {
const tabs = [
{ id: 'today', label: '今日' },
{ id: 'memory', label: '记忆' },
{ id: 'chat', label: '对话' },
];
return (
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0,
height: 72, background: 'rgba(253,251,245,0.95)',
backdropFilter: 'blur(12px)',
borderTop: `1px solid ${LINE}`,
display: 'flex', alignItems: 'center',
fontFamily: serif,
}}>
{tabs.map((t) => {
const isActive = active === t.id;
const isTapping = tapping === t.id;
return (
<div key={t.id} style={{
flex: 1, textAlign:'center', position:'relative',
padding: '12px 0 18px',
}}>
{/* Ripple */}
{isTapping !== null && isTapping > 0 && isTapping < 1 && (
<div style={{
position:'absolute', top:'50%', left:'50%',
transform: `translate(-50%, -50%) scale(${1 + isTapping * 2})`,
width: 44, height: 44, borderRadius:'50%',
background: TERRA, opacity: 0.25 * (1 - isTapping),
pointerEvents:'none',
}} />
)}
<div style={{
fontSize: 15, fontWeight: isActive ? 600 : 400,
fontStyle: isActive ? 'normal' : 'italic',
color: isActive ? TERRA : ASH,
letterSpacing: '0.02em',
}}>
{t.label}
</div>
{isActive && (
<div style={{
position:'absolute', bottom: 8, left:'50%',
transform: 'translateX(-50%)',
width: 18, height: 2, background: TERRA,
}} />
)}
</div>
);
})}
</div>
);
}
// ── Scene composition ─────────────────────────────────────
// Timeline:
// 0.0 – 1.8 iPhone fade+bounce in
// 1.8 – 7.5 Today screen (fills in)
// 7.5 – 8.5 Tap on Memory tab (ripple)
// 8.5 – 13.5 Memory screen
// 13.5 – 14.5 Tap on Chat tab
// 14.5 – 19.5 Chat screen
// 19.5 – 21.5 Pan: phone shrinks + capability labels appear
// 21.5 – 24.0 Hold final frame with labels
function App() {
return (
<Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
<MainComposition />
</Stage>
);
}
function MainComposition() {
const time = useTime();
// Phone entrance
const entranceT = Math.min(1, Math.max(0, time / 1.8));
const phoneOp = interpolate(entranceT, [0, 0.5], [0, 1]);
const phoneScale = interpolate(entranceT, [0, 1], [0.82, 0.88], Easing.spring);
// Pan-out in final scene (19.5 – 21.5)
const panT = Math.min(1, Math.max(0, (time - 19.5) / 2));
const finalScale = interpolate(panT, [0, 1], [0.88, 0.68], Easing.easeInOut);
const finalX = interpolate(panT, [0, 1], [0, -200], Easing.easeInOut);
const currentScale = panT > 0 ? finalScale : phoneScale;
const currentX = panT > 0 ? finalX : 0;
// Screen determination
let activeScreen = 'today';
let tapping = null; // { id: 'memory', t: 0..1 }
let screenAnimateT = 1;
let transitionProgress = 0;
if (time < 7.5) {
activeScreen = 'today';
screenAnimateT = Math.min(1, Math.max(0, (time - 1.8) / 2.5));
} else if (time < 8.5) {
activeScreen = 'today';
tapping = { id: 'memory', t: (time - 7.5) / 1.0 };
transitionProgress = (time - 8.0) / 0.5; // slide starts at 8.0
} else if (time < 13.5) {
activeScreen = 'memory';
screenAnimateT = Math.min(1, Math.max(0, (time - 8.5) / 2.5));
} else if (time < 14.5) {
activeScreen = 'memory';
tapping = { id: 'chat', t: (time - 13.5) / 1.0 };
transitionProgress = (time - 14.0) / 0.5;
} else if (time < 19.5) {
activeScreen = 'chat';
screenAnimateT = Math.min(1, Math.max(0, (time - 14.5) / 2.5));
} else {
activeScreen = 'chat';
screenAnimateT = 1;
}
return (
<div style={{position:'absolute', inset:0, background: CREAM}}>
{/* Phone */}
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: `translate(calc(-50% + ${currentX}px), -50%) scale(${currentScale})`,
opacity: phoneOp, transformOrigin: 'center center',
}}>
<IosFrame>
<div style={{position:'relative', width:'100%', height:'100%'}}>
{activeScreen === 'today' && <TodayScreen animateT={screenAnimateT} />}
{activeScreen === 'memory' && <MemoryScreen animateT={screenAnimateT} />}
{activeScreen === 'chat' && <ChatScreen animateT={screenAnimateT} />}
<TabBar active={activeScreen} tapping={tapping ? tapping.t : null} />
</div>
</IosFrame>
</div>
{/* Capability labels (appear during pan-out 19.5+) */}
{panT > 0.05 && <CapabilityLabels t={panT} />}
{/* Masthead (tucked corner, always visible from ~2s) */}
{time > 2 && time < 19.5 && (
<div style={{
position: 'absolute', top: 60, left: 80,
opacity: Math.min(1, (time - 2) / 0.6),
maxWidth: 420,
}}>
<div style={{fontFamily: mono, fontSize: 12, color: TERRA, letterSpacing:'0.3em', marginBottom: 10}}>
iOS APP 原型
</div>
<div style={{fontFamily: serif, fontSize: 70, fontWeight: 500, color: INK, lineHeight: 1.05, letterSpacing:'-0.015em'}}>
真机。<br/>
<span style={{fontStyle:'italic', color: TERRA}}>真</span>交互。
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20, color: ASH, marginTop: 22, lineHeight: 1.55}}>
iPhone 15 Pro 机身 · 灵动岛 · 状态驱动多屏<br/>
AI 密度信息 · CSS 艺术 · Playwright 点击测试
</div>
</div>
)}
{/* Screen label (bottom) */}
{time > 2 && time < 19.5 && (
<ScreenLabel active={activeScreen} time={time} />
)}
{/* Watermark */}
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
</div>
);
}
function ScreenLabel({ active, time }) {
const label = { today: '屏幕 1 · 今日', memory: '屏幕 2 · 记忆', chat: '屏幕 3 · 对话' }[active];
const idx = { today: 1, memory: 2, chat: 3 }[active];
return (
<div style={{
position: 'absolute', bottom: 80, left: 80,
fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.25em',
opacity: 0.9,
}}>
<span style={{color: TERRA, marginRight: 12}}>0{idx}</span>
<span>{label.toUpperCase()}</span>
</div>
);
}
function CapabilityLabels({ t }) {
const labels = [
{ text: '真图 · Wikimedia / Met / Unsplash', y: 220, delay: 0.0 },
{ text: 'Inline React · 双击就开', y: 380, delay: 0.15 },
{ text: 'AppPhone · 状态驱动多屏切换', y: 540, delay: 0.30 },
{ text: '信息密度型 · 每屏 ≥ 3 处差异化', y: 700, delay: 0.45 },
{ text: 'Playwright · 交付前点击测试', y: 860, delay: 0.60 },
];
return (
<>
{labels.map((l, i) => {
const localT = Math.max(0, Math.min(1, (t - l.delay) / 0.35));
const op = localT;
const x = interpolate(localT, [0, 1], [1400, 1280], Easing.easeOut);
return (
<div key={i} style={{
position: 'absolute', left: x, top: l.y,
opacity: op, display:'flex', alignItems:'center', gap: 14,
}}>
<div style={{width: 60, height: 1, background: TERRA}} />
<div>
<div style={{fontFamily: mono, fontSize: 10, color: TERRA, letterSpacing:'0.25em', marginBottom: 3}}>
0{i + 1}
</div>
<div style={{fontFamily: serif, fontSize: 20, color: INK, lineHeight: 1.25, letterSpacing:'-0.005em'}}>
{l.text}
</div>
</div>
</div>
);
})}
</>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+978
View File
@@ -0,0 +1,978 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Slides → PPTX</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: #0c0c0c;
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [inStart, inEnd] = input;
const [outStart, outEnd] = output;
if (t <= inStart) return outStart;
if (t >= inEnd) return outEnd;
let progress = (t - inStart) / (inEnd - inStart);
if (easing) progress = easing(progress);
return outStart + (outEnd - outStart) * progress;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() {
const sprite = useContext(SpriteContext);
return sprite || { t: 0, elapsed: 0, duration: 0 };
}
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
function updateScale() {
const vw = window.innerWidth;
const vh = window.innerHeight - 56;
const s = Math.min(vw / width, vh / height);
setScale(s);
}
updateScale();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false;
let last = null;
function tick(now) {
if (cancelled) return;
if (last === null) {
last = now;
if (typeof window !== 'undefined') window.__ready = true;
}
const delta = (now - last) / 1000;
last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
const canvasStyle = {
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
width,
height,
background: bgColor,
overflow: 'hidden',
transform: `translate(-50%, -50%) scale(${scale})`,
};
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={canvasStyle}>{children}</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
const spriteValue = { t, elapsed, duration, start, end: actualEnd };
return (
<SpriteContext.Provider value={spriteValue}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const OLIVE = '#6a6b4e';
const DEEP_BLUE = '#2a3552';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ══════════════════════════════════════════════════════════
// Scene 1 (0 – 3s) · 开题
// ══════════════════════════════════════════════════════════
function Scene1_Title() {
const { elapsed } = useSprite();
const tagOp = interpolate(elapsed, [0, 0.6], [0, 1]);
const mainOp = interpolate(elapsed, [0.4, 1.2], [0, 1]);
const mainY = interpolate(elapsed, [0.4, 1.2], [40, 0], Easing.easeOut);
const terraOp = interpolate(elapsed, [1.1, 1.8], [0, 1]);
const lineW = interpolate(elapsed, [1.6, 2.2], [0, 640]);
const subOp = interpolate(elapsed, [1.9, 2.5], [0, 1]);
const fadeOut = interpolate(elapsed, [2.7, 3.0], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{position:'absolute', top: 72, left: 88,
fontFamily: mono, fontSize: 12, letterSpacing:'0.3em',
color: ASH, opacity: tagOp}}>
<span style={{color: TERRA}}>●</span> 幻灯片能力 · HTML + PPTX
</div>
<div style={{fontFamily: serif, fontSize: 130, fontWeight: 500,
color: INK, lineHeight: 1.0, letterSpacing:'-0.015em',
opacity: mainOp, transform: `translateY(${mainY}px)`,
textAlign: 'center'}}>
<span style={{fontStyle:'italic'}}>播放</span>用 HTML,<br/>
<span style={{fontStyle:'italic', color: TERRA, opacity: terraOp}}>编辑</span>用 PPTX
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 40}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
color: ASH, marginTop: 24, opacity: subOp, letterSpacing:'0.02em'}}>
一个源文件,两种交付形态
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════
// Scene 2 (3 – 9s) · HTML Deck 翻页
// ══════════════════════════════════════════════════════════
function Scene2_DeckFlip() {
const { elapsed } = useSprite();
const frameOp = interpolate(elapsed, [0, 0.6], [0, 1]);
const frameScale = interpolate(elapsed, [0, 0.8], [0.94, 1], Easing.easeOut);
// Three pages, each ~1.5s. Stagger timings inside deck.
// Page 1: 0.6 – 2.2 | Page 2: 2.2 – 3.8 | Page 3: 3.8 – 5.6
const pageIndex = elapsed < 2.2 ? 0 : elapsed < 3.8 ? 1 : 2;
const pageNum = pageIndex + 1;
const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{position:'absolute', top: 48, left: 88,
fontFamily: mono, fontSize: 11, letterSpacing:'0.3em', color: ASH}}>
<span style={{color: TERRA}}>●</span> SCENE 02 · HTML DECK
</div>
<div style={{position:'absolute', top: 48, right: 88,
fontFamily: serif, fontStyle:'italic', fontSize: 20, color: ASH}}>
浏览器里直接演讲
</div>
<div style={{opacity: frameOp, transform: `scale(${frameScale})`,
transformOrigin:'center center'}}>
<BrowserFrame url="file:///Users/huashu/decks/annual-2026/deck.html">
<DeckSlide pageIndex={pageIndex} localElapsed={elapsed} />
{/* Footer inside deck */}
<div style={{position:'absolute', bottom: 18, left: 28, right: 28,
display:'flex', justifyContent:'space-between', alignItems:'center',
zIndex: 5}}>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.15em'}}>
{String(pageNum).padStart(2,'0')} / 12
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.2em'}}>
HUASHU · DESIGN
</div>
</div>
{/* TERRA progress bar */}
<div style={{position:'absolute', bottom: 0, left: 0, right: 0,
height: 3, background: '#eee', zIndex: 5}}>
<div style={{height:'100%', width: `${(pageNum/12)*100}%`,
background: TERRA}} />
</div>
</BrowserFrame>
</div>
<div style={{marginTop: 28, fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.25em'}}>
<span style={{color: pageIndex === 0 ? TERRA : LINE}}>●</span>
<span style={{margin:'0 10px', color: pageIndex === 1 ? TERRA : LINE}}>●</span>
<span style={{color: pageIndex === 2 ? TERRA : LINE}}>●</span>
</div>
</div>
);
}
// Browser chrome container (chrome style, 1600×900 deck 16:9)
function BrowserFrame({ url, children }) {
const W = 1400, H = 788; // 16:9 ratio
return (
<div style={{
display:'inline-block',
background:'#e8e4dc',
borderRadius: 12,
boxShadow:'0 30px 70px rgba(0,0,0,0.18), 0 10px 24px rgba(0,0,0,0.12)',
padding: 0,
overflow:'hidden',
border:`1px solid ${LINE}`,
}}>
{/* Title bar */}
<div style={{height: 42, display:'flex', alignItems:'center',
background:'#e8e4dc', padding:'0 16px', gap: 8,
borderBottom:`1px solid ${LINE}`}}>
<div style={{width:12, height:12, borderRadius:'50%', background:'#ff5f57'}} />
<div style={{width:12, height:12, borderRadius:'50%', background:'#febc2e'}} />
<div style={{width:12, height:12, borderRadius:'50%', background:'#28c840'}} />
<div style={{flex: 1, height: 26, background:'#faf6ef', border:`1px solid ${LINE}`,
borderRadius: 6, marginLeft: 16, padding:'0 14px',
display:'flex', alignItems:'center', gap: 8,
fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.02em',
overflow:'hidden', whiteSpace:'nowrap'}}>
<svg width="10" height="12" viewBox="0 0 10 12" style={{flexShrink: 0}}>
<path d="M2 5 V3.5 a3 3 0 016 0 V5" stroke={OLIVE} strokeWidth="1.2" fill="none"/>
<rect x="1" y="5" width="8" height="6" fill={OLIVE} opacity="0.85"/>
</svg>
<span style={{color: INK, opacity: 0.7}}>{url}</span>
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.15em'}}>DECK MODE</div>
</div>
{/* Deck area */}
<div style={{width: W, height: H, background:'#fff', position:'relative',
overflow:'hidden'}}>
{children}
</div>
</div>
);
}
// Three deck pages
function DeckSlide({ pageIndex, localElapsed }) {
// Slide-in entrance each time pageIndex changes
const pageStart = pageIndex === 0 ? 0.6 : pageIndex === 1 ? 2.2 : 3.8;
const sinceStart = localElapsed - pageStart;
const slideX = interpolate(sinceStart, [0, 0.5], [140, 0], Easing.easeOut);
const fadeIn = interpolate(sinceStart, [0, 0.4], [0, 1]);
return (
<div key={pageIndex} style={{position:'absolute', inset:0,
opacity: fadeIn, transform: `translateX(${slideX}px)`}}>
{pageIndex === 0 && <CoverPage />}
{pageIndex === 1 && <DataPage />}
{pageIndex === 2 && <QuotePage />}
</div>
);
}
function CoverPage() {
return (
<div style={{padding: '80px 80px 60px', height:'100%', background:'#fff',
display:'flex', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.3em',
color: TERRA, marginBottom: 14}}>
VOL.01 · ANNUAL REPORT
</div>
<div style={{flex: 1, display:'flex', flexDirection:'column',
justifyContent:'center'}}>
<div style={{fontFamily: serif, fontSize: 132, fontWeight: 500,
color: INK, lineHeight: 1.02, letterSpacing:'-0.02em'}}>
2026<br/>
<span style={{fontStyle:'italic'}}>设计年度</span>报告
</div>
<div style={{height: 1, background: INK, width: 380, marginTop: 36,
marginBottom: 28}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
color: ASH, letterSpacing:'0.02em'}}>
The shape of digital craft, from typography to motion.
</div>
</div>
</div>
);
}
function DataPage() {
const numbers = [
{ big: '428', label: '项目交付', unit: 'projects' },
{ big: '92%', label: '客户续约', unit: 'retention' },
{ big: '3.1x', label: '交付提速', unit: 'vs 2025' },
];
const bars = [
{ h: 0.45, label: 'Q1' },
{ h: 0.62, label: 'Q2' },
{ h: 0.78, label: 'Q3' },
{ h: 1.00, label: 'Q4', hi: true },
];
return (
<div style={{padding: '60px 80px 56px', height:'100%', background:'#fff',
display:'flex', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
color: TERRA, marginBottom: 10}}>SECTION 02 · NUMBERS</div>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
letterSpacing:'-0.015em', marginBottom: 36}}>
今年的三个关键数字
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap: 48,
marginBottom: 40}}>
{numbers.map((n, i) => (
<div key={i}>
<div style={{fontFamily: serif, fontSize: 112, fontWeight: 400,
color: i === 2 ? TERRA : INK, lineHeight: 1, letterSpacing:'-0.02em'}}>
{n.big}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
color: INK, marginTop: 10}}>
{n.label}
</div>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.2em', marginTop: 4}}>
{n.unit}
</div>
</div>
))}
</div>
<div style={{flex: 1, display:'flex', alignItems:'flex-end', gap: 20,
paddingLeft: 4, borderTop:`1px solid ${LINE}`, paddingTop: 24}}>
{bars.map((b, i) => (
<div key={i} style={{flex: 1, display:'flex', flexDirection:'column',
alignItems:'center'}}>
<div style={{width:'78%', height: `${b.h * 180}px`,
background: b.hi ? TERRA : INK, marginBottom: 10}} />
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.2em'}}>{b.label}</div>
</div>
))}
</div>
</div>
);
}
function QuotePage() {
return (
<div style={{padding: '80px', height:'100%', background:'#faf6ef',
display:'flex', flexDirection:'column', justifyContent:'center',
alignItems:'center', position:'relative'}}>
<div style={{position:'absolute', top: 64, left: 80,
fontFamily: mono, fontSize: 11, letterSpacing:'0.3em', color: TERRA}}>
EPIGRAPH · III
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 104,
fontWeight: 400, color: INK, lineHeight: 1.15, letterSpacing:'-0.015em',
textAlign:'center', maxWidth: 1100}}>
"Less,<br/>but <span style={{color: TERRA}}>better</span>."
</div>
<div style={{height: 1, background: INK, width: 140, marginTop: 44,
marginBottom: 20}} />
<div style={{fontFamily: serif, fontSize: 22, color: ASH,
letterSpacing:'0.08em'}}>
— Dieter Rams
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════
// Scene 3 (9 – 15s) · 导出流水线
// ══════════════════════════════════════════════════════════
function Scene3_Pipeline() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.6], [0, 1]);
const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
const nodes = [
{ title: 'HTML Deck', sub: 'source of truth', icon: 'code', delay: 0.4 },
{ title: 'html2pptx.js', sub: 'read computedStyle', icon: 'scan', delay: 1.1, hi: true },
{ title: 'pptxgenjs', sub: 'assemble objects', icon: 'compose', delay: 1.8 },
{ title: 'deck.pptx', sub: 'editable output', icon: 'doc', delay: 2.5 },
];
const cmdOp = interpolate(elapsed, [3.8, 4.4], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '72px 96px 56px', display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', opacity: titleOp, marginBottom: 12}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
color: TERRA, marginBottom: 10}}>
<span>●</span> SCENE 03 · EXPORT PIPELINE
</div>
<div style={{fontFamily: mono, fontSize: 64, fontWeight: 500,
color: INK, letterSpacing:'0.04em'}}>
导出流水线
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20,
color: ASH, textAlign:'right', maxWidth: 380, lineHeight: 1.5}}>
把 DOM 翻译成<br/>
PowerPoint 对象图
</div>
</div>
<div style={{height: 1, background: INK, width: '100%', opacity: titleOp,
marginTop: 28, marginBottom: 48}} />
{/* Pipeline nodes */}
<div style={{display:'flex', alignItems:'stretch', gap: 0, flex: 1,
position:'relative'}}>
{nodes.map((n, i) => {
const op = interpolate(elapsed, [n.delay, n.delay + 0.5], [0, 1]);
const ty = interpolate(elapsed, [n.delay, n.delay + 0.5], [28, 0], Easing.easeOut);
return (
<React.Fragment key={i}>
<div style={{flex: 1, opacity: op, transform: `translateY(${ty}px)`,
background: n.hi ? TERRA : '#fff',
border: `1px solid ${n.hi ? TERRA : LINE}`,
padding:'28px 24px', display:'flex', flexDirection:'column',
color: n.hi ? '#fff' : INK}}>
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.25em',
opacity: n.hi ? 0.85 : 0.5, marginBottom: 18}}>
STEP {String(i+1).padStart(2, '0')}
</div>
<NodeIcon kind={n.icon} hi={n.hi} />
<div style={{fontFamily: mono, fontSize: 20, fontWeight: 500,
marginTop: 20, letterSpacing:'0.01em'}}>
{n.title}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
opacity: n.hi ? 0.85 : 0.6, marginTop: 6}}>
{n.sub}
</div>
</div>
{i < nodes.length - 1 && (
<ArrowBetween elapsed={elapsed} startTime={n.delay + 0.4} />
)}
</React.Fragment>
);
})}
</div>
{/* Data flow caption */}
<div style={{marginTop: 36, display:'flex', alignItems:'center', gap: 24,
opacity: interpolate(elapsed, [3.2, 3.8], [0, 1])}}>
<div style={{fontFamily: mono, fontSize: 13, color: ASH,
letterSpacing:'0.05em', flex: 1}}>
<span style={{color: OLIVE}}>DOM node</span> <span style={{color: TERRA}}>→</span>{' '}
<span style={{color: INK}}>{'{ type, text, font, color, x, y }'}</span>
<span style={{color: ASH, margin:'0 14px'}}>·</span>
<span style={{color: TERRA}}>→</span> <span style={{color: INK}}>slide.addText(...) / slide.addShape(...)</span>
</div>
</div>
{/* Command subtitle */}
<div style={{marginTop: 22, opacity: cmdOp,
background:'#1a1a1a', padding:'16px 24px',
borderLeft: `3px solid ${TERRA}`,
display:'flex', alignItems:'center', gap: 16}}>
<div style={{fontFamily: mono, fontSize: 12, color: TERRA,
letterSpacing:'0.2em'}}>$</div>
<div style={{fontFamily: mono, fontSize: 15, color: '#f5f0e6',
letterSpacing:'0.02em'}}>
node export_deck_pptx.mjs deck.html <span style={{color: '#8ca577'}}>--mode editable</span>
</div>
</div>
</div>
);
}
function NodeIcon({ kind, hi }) {
const fg = hi ? '#fff' : INK;
const bg = hi ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.04)';
if (kind === 'code') {
return (
<div style={{width: 72, height: 72, background: bg,
display:'flex', alignItems:'center', justifyContent:'center'}}>
<svg width="34" height="34" viewBox="0 0 34 34" fill="none">
<path d="M12 10 L5 17 L12 24" stroke={fg} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M22 10 L29 17 L22 24" stroke={fg} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M19 7 L15 27" stroke={fg} strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
);
}
if (kind === 'scan') {
return (
<div style={{width: 72, height: 72, background: bg,
display:'flex', alignItems:'center', justifyContent:'center'}}>
<svg width="38" height="38" viewBox="0 0 38 38" fill="none">
<rect x="6" y="6" width="26" height="26" stroke={fg} strokeWidth="2"/>
<line x1="6" y1="15" x2="32" y2="15" stroke={fg} strokeWidth="1.5"/>
<line x1="6" y1="23" x2="32" y2="23" stroke={fg} strokeWidth="1.5"/>
<line x1="15" y1="6" x2="15" y2="32" stroke={fg} strokeWidth="1.5"/>
<line x1="23" y1="6" x2="23" y2="32" stroke={fg} strokeWidth="1.5"/>
<circle cx="19" cy="19" r="3" fill={fg}/>
</svg>
</div>
);
}
if (kind === 'compose') {
return (
<div style={{width: 72, height: 72, background: bg,
display:'flex', alignItems:'center', justifyContent:'center'}}>
<svg width="38" height="38" viewBox="0 0 38 38" fill="none">
<rect x="4" y="4" width="16" height="12" stroke={fg} strokeWidth="2"/>
<rect x="22" y="4" width="12" height="12" stroke={fg} strokeWidth="2"/>
<rect x="4" y="20" width="12" height="14" stroke={fg} strokeWidth="2"/>
<rect x="18" y="20" width="16" height="14" stroke={fg} strokeWidth="2"/>
</svg>
</div>
);
}
// doc
return (
<div style={{width: 72, height: 72, background: bg,
display:'flex', alignItems:'center', justifyContent:'center'}}>
<svg width="34" height="38" viewBox="0 0 34 38" fill="none">
<path d="M6 4 H22 L28 10 V34 H6 Z" stroke={fg} strokeWidth="2" strokeLinejoin="round"/>
<path d="M22 4 V10 H28" stroke={fg} strokeWidth="2" strokeLinejoin="round"/>
<line x1="11" y1="17" x2="23" y2="17" stroke={fg} strokeWidth="1.5"/>
<line x1="11" y1="22" x2="23" y2="22" stroke={fg} strokeWidth="1.5"/>
<line x1="11" y1="27" x2="19" y2="27" stroke={fg} strokeWidth="1.5"/>
</svg>
</div>
);
}
function ArrowBetween({ elapsed, startTime }) {
const reveal = interpolate(elapsed, [startTime, startTime + 0.3], [0, 1]);
return (
<div style={{width: 48, display:'flex', alignItems:'center',
justifyContent:'center', position:'relative'}}>
<svg width="48" height="24" viewBox="0 0 48 24" style={{opacity: reveal}}>
<line x1="0" y1="12" x2={34 * reveal + 8} y2="12" stroke={TERRA} strokeWidth="1.5"/>
{reveal > 0.6 && (
<path d="M38 6 L44 12 L38 18" stroke={TERRA} strokeWidth="1.5" fill="none"
strokeLinecap="round" strokeLinejoin="round"/>
)}
</svg>
</div>
);
}
// ══════════════════════════════════════════════════════════
// Scene 4 (15 – 20s) · 产物:可编辑文本框
// ══════════════════════════════════════════════════════════
function Scene4_PPTEdit() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.5], [0, 1]);
const pptScale = interpolate(elapsed, [0, 0.8], [0.94, 1], Easing.easeOut);
// Selection bounding box appears at 0.8s, handles animate in staggered
const selectOp = interpolate(elapsed, [0.9, 1.3], [0, 1]);
// Format panel slides in from right at 1.5s
const panelX = interpolate(elapsed, [1.6, 2.4], [80, 0], Easing.easeOut);
const panelOp = interpolate(elapsed, [1.6, 2.4], [0, 1]);
// Caption fades in 2.4s
const captionOp = interpolate(elapsed, [2.4, 3.0], [0, 1]);
// Checkboxes tick in sequentially
const chk1 = elapsed > 3.2 ? 1 : 0;
const chk2 = elapsed > 3.7 ? 1 : 0;
const chk3 = elapsed > 4.2 ? 1 : 0;
const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background: CREAM,
opacity: fadeIn * fadeOut,
display:'flex', flexDirection:'column', alignItems:'center',
padding:'60px 60px 40px'}}>
<div style={{width:'100%', display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 20}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
color: TERRA, marginBottom: 8}}>
<span>●</span> SCENE 04 · THE ARTIFACT
</div>
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
产物:可编辑文本框
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
textAlign:'right', maxWidth: 340, lineHeight: 1.5}}>
在 PowerPoint 里<br/>
像素级复现,字还是字
</div>
</div>
<div style={{position:'relative', transform: `scale(${pptScale})`,
transformOrigin:'center center'}}>
<PPTMockup selectOp={selectOp} />
{/* Format panel */}
<div style={{position:'absolute', top: 94, right: -296,
width: 272, background:'#f5f2ed', border:`1px solid ${LINE}`,
boxShadow:'0 12px 30px rgba(0,0,0,0.08)',
transform: `translateX(${panelX}px)`, opacity: panelOp,
padding: 0}}>
<FormatPanel />
</div>
</div>
<div style={{marginTop: 28, display:'flex', alignItems:'center', gap: 48,
opacity: captionOp}}>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
color: TERRA, letterSpacing:'0.01em'}}>
原生 PowerPoint 文本框 · 不是图片
</div>
<div style={{display:'flex', gap: 28, fontFamily: mono, fontSize: 13}}>
<CheckRow label="文字可编辑" on={chk1} />
<CheckRow label="字体保留" on={chk2} />
<CheckRow label="位置/颜色精确" on={chk3} />
</div>
</div>
</div>
);
}
function CheckRow({ label, on }) {
return (
<div style={{display:'flex', alignItems:'center', gap: 8}}>
<div style={{width: 18, height: 18, border:`1.5px solid ${on ? TERRA : LINE}`,
background: on ? TERRA : 'transparent',
display:'flex', alignItems:'center', justifyContent:'center',
transition:'none'}}>
{on ? (
<svg width="12" height="12" viewBox="0 0 12 12">
<path d="M2 6 L5 9 L10 3" stroke="#fff" strokeWidth="2" fill="none"
strokeLinecap="round" strokeLinejoin="round"/>
</svg>
) : null}
</div>
<span style={{color: on ? INK : ASH}}>{label}</span>
</div>
);
}
function PPTMockup({ selectOp }) {
const W = 1100, H = 620;
return (
<div style={{width: W, height: H, background:'#f4f1ec',
border:`1px solid ${LINE}`, boxShadow:'0 22px 50px rgba(0,0,0,0.14)',
display:'flex', flexDirection:'column'}}>
{/* PPT ribbon (title bar + tabs) */}
<div style={{height: 32, background:'#dcd7cd', display:'flex',
alignItems:'center', padding:'0 14px', gap: 8,
borderBottom:`1px solid ${LINE}`}}>
<div style={{width:10, height:10, borderRadius:'50%', background:'#ff5f57'}} />
<div style={{width:10, height:10, borderRadius:'50%', background:'#febc2e'}} />
<div style={{width:10, height:10, borderRadius:'50%', background:'#28c840'}} />
<div style={{flex: 1, textAlign:'center', fontFamily: sans, fontSize: 11,
color: ASH, letterSpacing:'0.02em'}}>
deck.pptx — PowerPoint
</div>
</div>
<div style={{height: 34, background:'#ebe7de', display:'flex',
alignItems:'center', padding:'0 18px', gap: 22,
fontFamily: sans, fontSize: 11, color: INK,
borderBottom:`1px solid ${LINE}`}}>
<span style={{color: TERRA, fontWeight: 600,
borderBottom: `2px solid ${TERRA}`, paddingBottom: 6,
marginBottom: -7}}>Home</span>
<span style={{opacity: 0.55}}>Insert</span>
<span style={{opacity: 0.55}}>Design</span>
<span style={{opacity: 0.55}}>Transitions</span>
<span style={{opacity: 0.55}}>Animations</span>
<span style={{opacity: 0.55}}>Slide Show</span>
<span style={{opacity: 0.55}}>Review</span>
<span style={{opacity: 0.55}}>View</span>
</div>
{/* Body: slide panel (left) + slide canvas (main) */}
<div style={{flex: 1, display:'flex'}}>
{/* Thumbnails */}
<div style={{width: 160, background:'#eae5db',
borderRight:`1px solid ${LINE}`, padding:'12px 12px',
display:'flex', flexDirection:'column', gap: 8}}>
{[0,1,2,3].map(i => (
<div key={i} style={{
background:'#fff',
border: i === 2 ? `2px solid ${TERRA}` : `1px solid ${LINE}`,
aspectRatio:'16/9', position:'relative',
padding: 8, display:'flex', alignItems:'center',
justifyContent:'center'}}>
<div style={{position:'absolute', top: 4, left: 4,
fontFamily: mono, fontSize: 8, color: ASH}}>{i+1}</div>
{i === 2 && (
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 12,
color: INK}}>"Less..."</div>
)}
{i !== 2 && (
<div style={{width:'70%', height: 3, background: LINE}} />
)}
</div>
))}
</div>
{/* Slide canvas */}
<div style={{flex: 1, background:'#e8e4dc', display:'flex',
alignItems:'center', justifyContent:'center', padding: 32,
position:'relative'}}>
<div style={{width: 720, height: 405, background:'#faf6ef',
boxShadow:'0 8px 24px rgba(0,0,0,0.1)',
border:`1px solid ${LINE}`, position:'relative'}}>
{/* The editable text box */}
<div style={{position:'absolute', top:'50%', left:'50%',
transform:'translate(-50%, -50%)', textAlign:'center',
padding:'18px 40px'}}>
<div style={{fontFamily: serif, fontStyle:'italic',
fontSize: 72, color: INK, lineHeight: 1.1,
letterSpacing:'-0.01em'}}>
"Less, but <span style={{color: TERRA}}>better</span>."
</div>
<div style={{fontFamily: serif, fontSize: 14, color: ASH,
marginTop: 14, letterSpacing:'0.1em'}}>
— Dieter Rams
</div>
</div>
{/* Selection bounding box + 8 handles */}
{selectOp > 0 && <SelectionBox opacity={selectOp} />}
{/* slide number */}
<div style={{position:'absolute', bottom: 10, right: 14,
fontFamily: sans, fontSize: 9, color: ASH}}>3</div>
</div>
</div>
</div>
</div>
);
}
function SelectionBox({ opacity }) {
// Box centered around the textbox (~ 520×160)
const BW = 560, BH = 170;
const color = '#4a9eff';
const handles = [
{ x: 0, y: 0 }, { x: 0.5, y: 0 }, { x: 1, y: 0 },
{ x: 0, y: 0.5 }, { x: 1, y: 0.5 },
{ x: 0, y: 1 }, { x: 0.5, y: 1 }, { x: 1, y: 1 },
];
return (
<div style={{position:'absolute', top:'50%', left:'50%',
width: BW, height: BH, transform:'translate(-50%, -50%)',
border: `1.5px solid ${color}`, opacity,
boxShadow:`0 0 0 1px rgba(255,255,255,0.6)`, pointerEvents:'none'}}>
{handles.map((h, i) => (
<div key={i} style={{position:'absolute',
left: `${h.x * 100}%`, top: `${h.y * 100}%`,
transform:'translate(-50%, -50%)',
width: 10, height: 10, background:'#fff',
border: `1.5px solid ${color}`, borderRadius: 2}} />
))}
{/* Rotate handle */}
<div style={{position:'absolute', left:'50%', top: -26,
transform:'translateX(-50%)',
width: 10, height: 10, background:'#fff',
border: `1.5px solid ${color}`, borderRadius: '50%'}} />
<div style={{position:'absolute', left:'50%', top: -17,
transform:'translateX(-50%)', width: 1, height: 9, background: color}} />
{/* Label: Text Box */}
<div style={{position:'absolute', top: -26, left: 0,
fontFamily: mono, fontSize: 10, color: color,
background:'rgba(255,255,255,0.9)', padding:'2px 6px',
letterSpacing:'0.1em'}}>
TEXT BOX · shape #1
</div>
</div>
);
}
function FormatPanel() {
return (
<div>
<div style={{padding:'12px 16px', borderBottom:`1px solid ${LINE}`,
display:'flex', justifyContent:'space-between', alignItems:'center',
background:'#ebe7de'}}>
<div style={{fontFamily: sans, fontSize: 12, color: INK,
fontWeight: 600}}>Format Text</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH}}>✕</div>
</div>
<div style={{padding:'16px'}}>
<div style={{fontFamily: sans, fontSize: 10, color: ASH,
letterSpacing:'0.15em', marginBottom: 8}}>FONT</div>
<div style={{background:'#fff', border:`1px solid ${LINE}`,
padding:'8px 10px', marginBottom: 14,
display:'flex', justifyContent:'space-between', alignItems:'center',
fontFamily: sans, fontSize: 12, color: INK}}>
<span style={{fontFamily: serif, fontStyle:'italic'}}>Newsreader</span>
<span style={{color: ASH, fontSize: 10}}>▾</span>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 10,
marginBottom: 14}}>
<div>
<div style={{fontFamily: sans, fontSize: 10, color: ASH,
letterSpacing:'0.15em', marginBottom: 6}}>SIZE</div>
<div style={{background:'#fff', border:`1px solid ${LINE}`,
padding:'6px 10px', fontFamily: sans, fontSize: 12}}>72 pt</div>
</div>
<div>
<div style={{fontFamily: sans, fontSize: 10, color: ASH,
letterSpacing:'0.15em', marginBottom: 6}}>WEIGHT</div>
<div style={{background:'#fff', border:`1px solid ${LINE}`,
padding:'6px 10px', fontFamily: sans, fontSize: 12}}>400 · italic</div>
</div>
</div>
<div style={{fontFamily: sans, fontSize: 10, color: ASH,
letterSpacing:'0.15em', marginBottom: 6}}>COLOR</div>
<div style={{display:'flex', gap: 8, marginBottom: 14}}>
<div style={{width: 28, height: 28, background: INK, border:`2px solid ${INK}`}} />
<div style={{width: 28, height: 28, background: TERRA,
outline:`2px solid ${TERRA}`, outlineOffset: 1}} />
<div style={{width: 28, height: 28, background: OLIVE}} />
<div style={{width: 28, height: 28, background: DEEP_BLUE}} />
<div style={{width: 28, height: 28, background:'#fff', border:`1px solid ${LINE}`}} />
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.1em', lineHeight: 1.6,
paddingTop: 10, borderTop:`1px solid ${LINE}`}}>
x: 2.4in · y: 2.1in<br/>
w: 5.8in · h: 1.7in
</div>
</div>
</div>
);
}
// ══════════════════════════════════════════════════════════
// Scene 5 (20 – 24s) · 收尾
// ══════════════════════════════════════════════════════════
function Scene5_Final() {
const { elapsed } = useSprite();
const tagOp = interpolate(elapsed, [0, 0.5], [0, 1]);
const mainY = interpolate(elapsed, [0.2, 1.2], [50, 0], Easing.easeOut);
const mainOp = interpolate(elapsed, [0.2, 1.0], [0, 1]);
const lineW = interpolate(elapsed, [1.1, 1.8], [0, 540]);
const subOp = interpolate(elapsed, [1.5, 2.2], [0, 1]);
const monoOp = interpolate(elapsed, [2.2, 2.8], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background: CREAM,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 32, opacity: tagOp}}>
ONE SOURCE · TWO STATES
</div>
<div style={{fontFamily: serif, fontSize: 220, fontWeight: 500,
color: INK, lineHeight: 0.98, letterSpacing:'-0.03em',
opacity: mainOp, transform: `translateY(${mainY}px)`}}>
一<span style={{color: ASH, fontStyle:'italic'}}>源</span>
<span style={{color: TERRA, margin:'0 28px'}}>·</span>
双<span style={{color: ASH, fontStyle:'italic'}}>态</span>
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 46}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 28,
color: ASH, marginTop: 28, opacity: subOp, letterSpacing:'0.02em'}}>
浏览器里演讲 · PowerPoint 里二次编辑
</div>
<div style={{fontFamily: mono, fontSize: 18, color: INK, marginTop: 34,
opacity: monoOp, letterSpacing:'0.1em',
padding:'12px 28px', background:'#fff', border:`1px solid ${LINE}`}}>
<span style={{color: OLIVE}}>deck.html</span>
<span style={{color: TERRA, margin:'0 14px'}}>⇌</span>
<span style={{color: DEEP_BLUE}}>deck.pptx</span>
</div>
</div>
);
}
// ── Watermark ─────────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
// ── Main composition ──────────────────────────────────────
function App() {
return (
<Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
<Sprite start={3} end={9}><Scene2_DeckFlip /></Sprite>
<Sprite start={9} end={15}><Scene3_Pipeline /></Sprite>
<Sprite start={15} end={20}><Scene4_PPTEdit /></Sprite>
<Sprite start={20} end={24}><Scene5_Final /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+556
View File
@@ -0,0 +1,556 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Motion Design</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [a, b] = input, [x, y] = output;
if (t <= a) return x; if (t >= b) return y;
let p = (t - a) / (b - a); if (easing) p = easing(p);
return x + (y - x) * p;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
const update = () => {
const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
setScale(s);
};
update(); window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false, last = null;
function tick(now) {
if (cancelled) return;
if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
const delta = (now - last) / 1000; last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
{children}
</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
return (
<SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const OLIVE = '#6a6b4e';
const DEEP_BLUE = '#2a3552';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── Scene 1: Title (0 – 3s) ────────────────────────────
function Scene1_Title() {
const { elapsed } = useSprite();
const titleY = interpolate(elapsed, [0, 1.2], [60, 0], Easing.easeOut);
const titleOp = interpolate(elapsed, [0, 0.8], [0, 1]);
const subOp = interpolate(elapsed, [0.6, 1.4], [0, 1]);
const lineW = interpolate(elapsed, [0.9, 1.6], [0, 520]);
const apiOp = interpolate(elapsed, [1.4, 2.2], [0, 1]);
const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 24, opacity: titleOp}}>
动画引擎 · Stage + Sprite
</div>
<div style={{fontFamily: serif, fontSize: 148, fontWeight: 500, color: INK,
lineHeight: 1, letterSpacing:'-0.02em', opacity: titleOp,
transform: `translateY(${titleY}px)`}}>
<span style={{fontStyle:'italic', color: TERRA}}>Motion</span> Design
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24, color: ASH,
marginTop: 28, opacity: subOp}}>
时间驱动 · 可编排 · 60fps 导出
</div>
<div style={{fontFamily: mono, fontSize: 14, color: ASH,
marginTop: 40, opacity: apiOp, letterSpacing:'0.1em'}}>
&lt;Stage&gt; · &lt;Sprite&gt; · useTime() · useSprite() · interpolate() · Easing
</div>
</div>
);
}
// ── Scene 2: Easing functions comparison (3 – 8s) ────────
function Scene2_Easing() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
// Lane sweep cycle every 2s
const cycle = (elapsed % 2.2) / 2.0;
const sweepT = Math.min(1, Math.max(0, cycle));
const curves = [
{ name: 'linear', label: 'linear', fn: Easing.linear, color: ASH },
{ name: 'easeOut', label: 'easeOut', fn: Easing.easeOut, color: OLIVE },
{ name: 'spring', label: 'spring', fn: Easing.spring, color: TERRA },
{ name: 'easeInOut', label: 'easeInOut', fn: Easing.easeInOut, color: DEEP_BLUE },
];
const trackLeft = 320;
const trackRight = 1480;
const trackLen = trackRight - trackLeft;
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '80px 100px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: titleOp, marginBottom: 50,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>场景 1 · EASING</div>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
四种缓动曲线同跑
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
textAlign:'right'}}>
同样的 2 秒,<br/>
走出四种不同的「节奏感」
</div>
</div>
<div style={{flex: 1, position:'relative'}}>
{curves.map((c, i) => {
const y = 80 + i * 140;
const t = c.fn(sweepT);
const x = trackLeft + trackLen * t;
// Draw the curve as a mini sparkline right of track
const sparkW = 160, sparkH = 50;
const sparkPts = Array.from({length: 30}, (_, k) => {
const tx = k / 29;
const ty = 1 - c.fn(tx);
return `${tx * sparkW},${ty * sparkH}`;
}).join(' ');
return (
<div key={i}>
{/* Label (left) */}
<div style={{position:'absolute', left: 0, top: y - 22, width: 280,
fontFamily: mono, fontSize: 14, color: INK, letterSpacing:'0.05em'}}>
<span style={{color: c.color, marginRight: 12}}>●</span>
Easing.<span style={{color: c.color}}>{c.label}</span>
</div>
{/* Track */}
<div style={{position:'absolute', left: trackLeft, top: y,
width: trackLen, height: 2, background: LINE}} />
{/* Dot */}
<div style={{position:'absolute', left: x - 14, top: y - 14,
width: 28, height: 28, borderRadius: '50%',
background: c.color,
boxShadow: `0 4px 12px ${c.color}55`}} />
{/* Sparkline */}
<svg style={{position:'absolute', left: trackRight + 60, top: y - sparkH/2 - 5,
width: sparkW, height: sparkH}}>
<polyline points={sparkPts} stroke={c.color} strokeWidth="1.5" fill="none" />
<circle cx={sweepT * sparkW} cy={(1 - c.fn(sweepT)) * sparkH}
r="3.5" fill={c.color} />
</svg>
</div>
);
})}
</div>
{/* Timeline at bottom */}
<div style={{marginTop: 10, position:'relative', height: 30}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.15em'}}>
t = <span style={{color: INK}}>{sweepT.toFixed(2)}</span> &nbsp; · &nbsp; 周期 2.0s
</div>
</div>
</div>
);
}
// ── Scene 3: interpolate() function demo (8 – 14s) ───────
function Scene3_Interpolate() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
// Animated t value: 0→1 cycle over ~3s
const cycle = (elapsed % 3.2) / 3.0;
const t = Math.min(1, Math.max(0, cycle));
// Three mapped outputs from same t:
const opacity = interpolate(t, [0, 1], [0, 1]);
const scale = interpolate(t, [0, 1], [0.4, 1.2], Easing.spring);
const rotation = interpolate(t, [0, 1], [-30, 30], Easing.easeInOut);
const translateX = interpolate(t, [0, 0.5, 1], [-80, 40, 0]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '80px 100px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: titleOp, marginBottom: 40,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>场景 2 · INTERPOLATE</div>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
一个 <span style={{fontStyle:'italic', color: TERRA}}>t</span>,四种变化
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
textAlign:'right'}}>
用同一条时间轴,<br/>
映射出透明度、尺寸、旋转、位移
</div>
</div>
{/* t value progress bar */}
<div style={{background:'#fff', border: `1px solid ${LINE}`,
padding: '20px 32px', marginBottom: 30}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 14}}>
<div style={{fontFamily: mono, fontSize: 13, color: INK}}>
<span style={{color: ASH}}>const</span> t = <span style={{color: TERRA}}>{t.toFixed(3)}</span>
</div>
<div style={{fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.15em'}}>
时间 → 0 到 1
</div>
</div>
<div style={{height: 4, background: LINE, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%',
width: `${t * 100}%`, background: TERRA}} />
</div>
</div>
{/* Four demos */}
<div style={{flex: 1, display:'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 24}}>
{[
{ name: 'opacity', code: 'interpolate(t, [0,1], [0,1])', val: opacity, render:
<div style={{width: 120, height: 120, background: TERRA, opacity}} /> },
{ name: 'scale + spring', code: 'interpolate(t, [0,1], [0.4,1.2], spring)', val: scale, render:
<div style={{width: 120, height: 120, background: OLIVE,
transform: `scale(${scale})`}} /> },
{ name: 'rotate', code: 'interpolate(t, [0,1], [-30,30], easeInOut)', val: rotation, render:
<div style={{width: 120, height: 120, background: DEEP_BLUE,
transform: `rotate(${rotation}deg)`}} /> },
{ name: 'translateX (3 stops)', code: 'interpolate(t, [0,.5,1], [-80,40,0])', val: translateX, render:
<div style={{width: 120, height: 120, background: INK,
transform: `translateX(${translateX}px)`}} /> },
].map((d, i) => (
<div key={i} style={{background:'#fff', border:`1px solid ${LINE}`,
padding: '18px 18px 14px', display:'flex', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 9, color: TERRA,
letterSpacing:'0.2em', marginBottom: 6}}>0{i+1}</div>
<div style={{fontFamily: serif, fontSize: 18, fontWeight: 500, color: INK,
marginBottom: 6}}>{d.name}</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
marginBottom: 20, minHeight: 32, wordBreak:'break-all', lineHeight: 1.5}}>
{d.code}
</div>
<div style={{flex: 1, display:'flex', alignItems:'center',
justifyContent:'center', overflow:'hidden'}}>
{d.render}
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
marginTop: 12, textAlign:'right'}}>
= <span style={{color: TERRA}}>{d.val.toFixed(2)}</span>
</div>
</div>
))}
</div>
</div>
);
}
// ── Scene 4: Sprite sequencing on timeline (14 – 20s) ───
function Scene4_Sprite() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
// Timeline plays 6s
const localTime = Math.min(elapsed, 5.6);
const sprites = [
{ name: 'Title', start: 0.0, end: 2.5, color: TERRA, y: 0, label: '标题' },
{ name: 'Image', start: 0.8, end: 3.5, color: OLIVE, y: 1, label: '图像淡入' },
{ name: 'Text', start: 1.8, end: 4.5, color: DEEP_BLUE, y: 2, label: '正文' },
{ name: 'Outro', start: 4.0, end: 5.5, color: '#8b4a2b', y: 3, label: '结尾' },
];
const timelineLeft = 100;
const timelineRight = 1820;
const timelineW = timelineRight - timelineLeft;
const totalDur = 5.6;
const cursorX = timelineLeft + (localTime / totalDur) * timelineW;
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '80px 100px 60px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: titleOp, marginBottom: 30,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>场景 3 · SPRITE 编排</div>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
时间片段 · <span style={{fontStyle:'italic'}}>同台起舞</span>
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
textAlign:'right'}}>
每个 &lt;Sprite start=... end=...&gt;<br/>
在自己的时间窗口出场、退场
</div>
</div>
{/* Live visualization area */}
<div style={{background:'#fff', border:`1px solid ${LINE}`, flex: 1,
position:'relative', overflow:'hidden', marginBottom: 30}}>
{sprites.map((s, i) => {
const active = localTime >= s.start && localTime < s.end;
if (!active) return null;
const localT = (localTime - s.start) / (s.end - s.start);
const op = interpolate(localT, [0, 0.15, 0.85, 1], [0, 1, 1, 0]);
const ty = interpolate(localT, [0, 0.2], [30, 0], Easing.easeOut);
if (s.name === 'Title') {
return (
<div key={i} style={{position:'absolute', top: 60, left: 80, right: 80,
opacity: op, transform: `translateY(${ty}px)`}}>
<div style={{fontFamily: mono, fontSize: 10, color: s.color,
letterSpacing:'0.3em', marginBottom: 10}}>CHAPTER 01</div>
<div style={{fontFamily: serif, fontSize: 64, fontWeight: 500,
color: INK, lineHeight: 1.05, letterSpacing:'-0.01em'}}>
如何让动画 <span style={{fontStyle:'italic', color: s.color}}>好看</span>
</div>
</div>
);
}
if (s.name === 'Image') {
return (
<div key={i} style={{position:'absolute', top: 60, right: 80,
width: 380, height: 240, opacity: op,
transform: `translateY(${ty}px)`,
background: `linear-gradient(135deg, ${s.color}, ${s.color}88 50%, ${s.color}33)`,
overflow: 'hidden'}}>
<div style={{position:'absolute', inset: 0,
background: `radial-gradient(circle at 30% 30%, ${s.color}aa, transparent 50%)`}} />
<div style={{position:'absolute', bottom: 14, left: 16,
fontFamily: mono, fontSize: 9, color: '#fff',
letterSpacing:'0.2em', opacity: 0.8}}>
IMAGE · FADE-IN
</div>
</div>
);
}
if (s.name === 'Text') {
return (
<div key={i} style={{position:'absolute', bottom: 80, left: 80, right: 80,
opacity: op, transform: `translateY(${ty}px)`}}>
<div style={{fontFamily: mono, fontSize: 10, color: s.color,
letterSpacing:'0.3em', marginBottom: 10}}>BODY</div>
<div style={{fontFamily: serif, fontSize: 20, color: INK,
lineHeight: 1.55, maxWidth: 720}}>
好的 motion 不是每个元素都在抢戏——是<span style={{fontStyle:'italic'}}>一个
</span>进、<span style={{fontStyle:'italic'}}>一个</span>退、留足呼吸,
最后合奏收尾。
</div>
</div>
);
}
if (s.name === 'Outro') {
return (
<div key={i} style={{position:'absolute', inset: 0,
opacity: op, display:'flex', alignItems:'center',
justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: serif, fontSize: 88, fontWeight: 500,
color: s.color, fontStyle:'italic', letterSpacing:'-0.01em'}}>
— fin —
</div>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.3em', marginTop: 14}}>
4 SPRITES · 5.5 SECONDS · 1 STAGE
</div>
</div>
);
}
})}
</div>
{/* Timeline viz (showing sprite spans) */}
<div style={{position:'relative', height: 110}}>
{/* Labels */}
{sprites.map((s, i) => (
<div key={i} style={{position:'absolute', left: 0, top: i * 22,
fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.05em'}}>
<span style={{color: s.color, marginRight: 8}}>●</span>{s.label}
</div>
))}
{/* Tracks */}
{sprites.map((s, i) => {
const x0 = timelineLeft + (s.start / totalDur) * timelineW;
const x1 = timelineLeft + (s.end / totalDur) * timelineW;
const active = localTime >= s.start && localTime < s.end;
return (
<div key={i} style={{position:'absolute',
left: x0, top: i * 22 - 2, width: x1 - x0, height: 16,
background: active ? s.color : `${s.color}55`,
borderLeft: `2px solid ${s.color}`}} />
);
})}
{/* Playhead cursor */}
<div style={{position:'absolute', left: cursorX - 1, top: -6,
width: 2, height: 110, background: INK, zIndex: 5}} />
<div style={{position:'absolute', left: cursorX - 20, top: -20,
fontFamily: mono, fontSize: 10, color: INK,
letterSpacing:'0.1em'}}>
{localTime.toFixed(2)}s
</div>
</div>
</div>
);
}
// ── Scene 5: Outro (20 – 22s) ─────────────────────────
function Scene5_Outro() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
const lineW = interpolate(elapsed, [0.5, 1.3], [0, 620]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 20}}>
导出 · MP4 / GIF / 60FPS / BGM
</div>
<div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
color: INK, lineHeight: 1, letterSpacing:'-0.015em'}}>
从 <span style={{fontStyle:'italic', color: TERRA}}>时间</span>,到成片
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
marginTop: 26, maxWidth: 800, textAlign:'center', lineHeight: 1.55}}>
render-video.js · convert-formats.sh · add-music.sh<br/>
一条命令跑完,产出社交媒体可直接用的素材
</div>
</div>
);
}
// ── Watermark ──────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
function App() {
return (
<Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
<Sprite start={3} end={8}><Scene2_Easing /></Sprite>
<Sprite start={8} end={14}><Scene3_Interpolate /></Sprite>
<Sprite start={14} end={20}><Scene4_Sprite /></Sprite>
<Sprite start={20} end={22}><Scene5_Outro /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+762
View File
@@ -0,0 +1,762 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Tweaks 实时变体</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: #0c0c0c;
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [inStart, inEnd] = input;
const [outStart, outEnd] = output;
if (t <= inStart) return outStart;
if (t >= inEnd) return outEnd;
let progress = (t - inStart) / (inEnd - inStart);
if (easing) progress = easing(progress);
return outStart + (outEnd - outStart) * progress;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() {
const sprite = useContext(SpriteContext);
return sprite || { t: 0, elapsed: 0, duration: 0 };
}
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
function updateScale() {
const vw = window.innerWidth;
const vh = window.innerHeight - 56;
const s = Math.min(vw / width, vh / height);
setScale(s);
}
updateScale();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false;
let last = null;
function tick(now) {
if (cancelled) return;
if (last === null) {
last = now;
if (typeof window !== 'undefined') window.__ready = true;
}
const delta = (now - last) / 1000;
last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
const canvasStyle = {
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
width,
height,
background: bgColor,
overflow: 'hidden',
transform: `translate(-50%, -50%) scale(${scale})`,
};
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={canvasStyle}>{children}</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
const spriteValue = { t, elapsed, duration, start, end: actualEnd };
return (
<SpriteContext.Provider value={spriteValue}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const OLIVE = '#6a6b4e';
const DEEP_BLUE = '#2a3552';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sansCN = "'Noto Sans SC', -apple-system, sans-serif";
const playfair = "'Playfair Display', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// Palettes — the actual Tweak presets
const PALETTES = {
warm: { bg: CREAM, accent: TERRA, text: INK, sub: ASH, line: LINE, name: '暖米 + 赤陶', enName: 'WARM · TERRA' },
olive: { bg: '#f2efdf', accent: OLIVE, text: '#2a2a1e', sub: '#7a7a5e', line: '#d4d1b8', name: '墨绿 + 鹅黄', enName: 'OLIVE · CITRON' },
deep: { bg: '#f4efe6', accent: DEEP_BLUE, text: INK, sub: '#5a6478', line: '#c9c3b3', name: '深蓝 + 沙金', enName: 'DEEP · SAND' },
};
const FONTS = {
serif: { ui: serif, display: serif, name: 'Newsreader(衬线)' },
sans: { ui: sansCN, display: sansCN, name: '思源黑体' },
play: { ui: playfair, display: playfair, name: 'Playfair Display' },
};
// ── Scene 1: Title (0 – 3s) ────────────────────────────────
function Scene1_Title() {
const { elapsed } = useSprite();
const labelOp = interpolate(elapsed, [0.2, 0.8], [0, 1]);
const mainY = interpolate(elapsed, [0.3, 1.3], [40, 0], Easing.easeOut);
const mainOp = interpolate(elapsed, [0.3, 1.1], [0, 1]);
const lineW = interpolate(elapsed, [1.1, 1.9], [0, 460]);
const subOp = interpolate(elapsed, [1.4, 2.1], [0, 1]);
const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0], Easing.easeIn);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 14, letterSpacing:'0.35em',
color: TERRA, marginBottom: 30, opacity: labelOp}}>
实时变体 · TWEAKS
</div>
<div style={{fontFamily: serif, fontSize: 120, fontWeight: 500, color: INK,
lineHeight: 1.05, letterSpacing: '-0.01em',
opacity: mainOp, transform: `translateY(${mainY}px)`}}>
一个 HTML · <span style={{fontStyle:'italic', color: TERRA}}>多种</span>设计
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 40}} />
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 22,
color: ASH, marginTop: 26, opacity: subOp, letterSpacing:'0.02em'}}>
不需要重新生成代码 · 只切参数
</div>
</div>
);
}
// ── Scene 2: Main stage — control panel + live card (3 – 12s) ──
function Scene2_MainStage() {
const { elapsed } = useSprite();
// Decide current Tweaks state based on elapsed time inside the scene.
// Scene2 elapsed: 0 – 9s
// 0 – 4s : warm + serif + 40
// 4 – 7s : olive + serif + 40 (palette change @ 4s)
// 7 – 9s : olive + sans + 40 (font change @ 7s)
let palette = 'warm';
let font = 'serif';
const density = 40;
if (elapsed >= 4) palette = 'olive';
if (elapsed >= 7) font = 'sans';
// Ripple trigger times
const rippleTimes = [4, 7];
const ripples = rippleTimes.map(t => {
const e = elapsed - t;
if (e < 0 || e > 1.2) return null;
return { t, progress: e / 1.2 };
}).filter(Boolean);
// Fade-in intro
const introOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const fadeOut = interpolate(elapsed, [8.6, 9.0], [1, 0]);
const pal = PALETTES[palette];
const fnt = FONTS[font];
return (
<div style={{position:'absolute', inset:0, background:CREAM,
display:'flex', opacity: introOp * fadeOut}}>
{/* Left: Control Panel (30%) */}
<ControlPanel palette={palette} font={font} density={density} elapsed={elapsed} />
{/* Right: Live Card (70%) */}
<div style={{flex: 1, position:'relative', padding: '60px 80px',
display:'flex', alignItems:'center', justifyContent:'center',
transition: 'background 600ms ease-in-out',
background: pal.bg}}>
<LiveCard palette={palette} font={font} density={density} />
{/* Ripples */}
{ripples.map((r, i) => (
<Ripple key={r.t} progress={r.progress}
x={r.t === 4 ? 180 : 180}
y={r.t === 4 ? 340 : 490} />
))}
</div>
</div>
);
}
function ControlPanel({ palette, font, density, elapsed }) {
return (
<div style={{width: '30%', background: '#f2ece0',
borderRight: `1px solid ${LINE}`, padding: '60px 44px 40px',
display:'flex', flexDirection:'column', gap: 38,
fontFamily: sans}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.35em',
color: TERRA, marginBottom: 6}}>
TWEAKS
</div>
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
设计调参面板
</div>
</div>
{/* Group 1: palette */}
<ControlGroup label="01 · 配色方案" en="PALETTE">
<Radio checked={palette==='warm'} label="暖米 + 赤陶" swatches={[CREAM, TERRA]} />
<Radio checked={palette==='olive'} label="墨绿 + 鹅黄" swatches={['#f2efdf', OLIVE]} />
<Radio checked={palette==='deep'} label="深蓝 + 沙金" swatches={['#f4efe6', DEEP_BLUE]} />
</ControlGroup>
{/* Group 2: font */}
<ControlGroup label="02 · 字型" en="TYPEFACE">
<Radio checked={font==='serif'} label="Newsreader(衬线)" fontFamily={serif} />
<Radio checked={font==='sans'} label="思源黑体" fontFamily={sansCN} />
<Radio checked={font==='play'} label="Playfair Display" fontFamily={playfair} />
</ControlGroup>
{/* Group 3: density */}
<ControlGroup label="03 · 信息密度" en="DENSITY">
<div style={{position:'relative', height: 4, background:'#e0dbcc',
marginTop: 16, marginBottom: 10}}>
<div style={{position:'absolute', left: 0, top: 0, height:'100%',
width: `${density}%`, background: TERRA}} />
<div style={{position:'absolute', left: `${density}%`, top: -6,
transform:'translateX(-50%)', width: 16, height: 16,
borderRadius:'50%', background: TERRA,
boxShadow:'0 2px 6px rgba(0,0,0,0.15)'}} />
</div>
<div style={{display:'flex', justifyContent:'space-between',
fontFamily: mono, fontSize: 9, letterSpacing:'0.2em', color: ASH}}>
<span>克制</span><span style={{color: TERRA}}>标准</span><span>密集</span>
</div>
</ControlGroup>
<div style={{flex: 1}} />
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.12em',
color: ASH, lineHeight: 1.6, borderTop: `1px solid ${LINE}`,
paddingTop: 16}}>
localStorage 持久化<br/>
<span style={{color: TERRA}}>→</span> 刷新不丢
</div>
</div>
);
}
function ControlGroup({ label, en, children }) {
return (
<div>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 14}}>
<div style={{fontFamily: serif, fontSize: 15, fontWeight: 500, color: INK}}>
{label}
</div>
<div style={{fontFamily: mono, fontSize: 9, letterSpacing:'0.25em',
color: ASH}}>{en}</div>
</div>
<div style={{display:'flex', flexDirection:'column', gap: 8}}>{children}</div>
</div>
);
}
function Radio({ checked, label, swatches, fontFamily }) {
return (
<div style={{display:'flex', alignItems:'center', gap: 12,
padding:'9px 12px', background: checked ? '#fff' : 'transparent',
border: `1px solid ${checked ? TERRA : 'transparent'}`,
transition:'all 240ms ease-out'}}>
<div style={{width: 14, height: 14, borderRadius:'50%',
border: `1.5px solid ${checked ? TERRA : '#b0a898'}`,
display:'flex', alignItems:'center', justifyContent:'center',
flexShrink: 0}}>
{checked && <div style={{width: 7, height: 7, borderRadius:'50%',
background: TERRA}} />}
</div>
<div style={{flex: 1, fontFamily: fontFamily || sans, fontSize: 13,
color: checked ? INK : '#4a4a4a'}}>{label}</div>
{swatches && (
<div style={{display:'flex', gap: 3}}>
{swatches.map((c, i) => (
<div key={i} style={{width: 12, height: 12, background: c,
border:'1px solid rgba(0,0,0,0.06)'}} />
))}
</div>
)}
</div>
);
}
function Ripple({ progress, x, y }) {
const size = progress * 420;
const op = 1 - progress;
return (
<div style={{position:'absolute', left: x, top: y,
width: size, height: size, borderRadius:'50%',
border: `2px solid ${TERRA}`, opacity: op,
transform: 'translate(-50%, -50%)',
pointerEvents:'none'}} />
);
}
function LiveCard({ palette, font, density }) {
const pal = PALETTES[palette];
const fnt = FONTS[font];
return (
<div style={{width: '100%', maxWidth: 880, background: '#fff',
border: `1px solid ${pal.line}`,
transition:'border-color 600ms ease-in-out',
position:'relative'}}>
{/* Header bar */}
<div style={{padding:'18px 32px', borderBottom:`1px solid ${pal.line}`,
display:'flex', justifyContent:'space-between', alignItems:'center',
transition:'border-color 600ms ease-in-out'}}>
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
color: pal.accent, transition:'color 600ms ease-in-out'}}>
LUMINA · v3.2
</div>
<div style={{fontFamily: mono, fontSize: 10, color: pal.sub,
letterSpacing:'0.15em', transition:'color 600ms ease-in-out'}}>
PALETTE · {pal.enName}
</div>
</div>
{/* Hero content */}
<div style={{padding:'56px 60px 48px', display:'grid',
gridTemplateColumns:'1.4fr 1fr', gap: 48}}>
<div>
<div style={{fontFamily: mono, fontSize: 11,
color: pal.accent, letterSpacing:'0.25em', marginBottom: 16,
transition:'color 600ms ease-in-out'}}>
READING · MEMORY
</div>
<div style={{fontFamily: fnt.display, fontSize: 68,
fontWeight: font === 'sans' ? 700 : 500, color: pal.text,
lineHeight: 1.05, letterSpacing:'-0.02em',
transition:'color 600ms ease-in-out',
marginBottom: 14}}>
Lumina
</div>
<div style={{fontFamily: fnt.ui,
fontStyle: font === 'play' ? 'italic' : 'normal',
fontSize: 22, color: pal.sub, lineHeight: 1.4,
letterSpacing: font === 'sans' ? 0 : '0.01em',
transition:'color 600ms ease-in-out',
marginBottom: 28}}>
阅读记忆 · 让每一次阅读被看见
</div>
<div style={{fontFamily: fnt.ui, fontSize: 14, color: pal.text,
lineHeight: 1.7, opacity: 0.78, marginBottom: 32,
transition:'color 600ms ease-in-out'}}>
把你读过的每一行、标注过的每一段,<br/>
汇成一条属于你的阅读河流。
</div>
<div style={{display:'flex', gap: 12, alignItems:'center'}}>
<div style={{padding:'12px 26px', background: pal.accent,
color:'#fff', fontFamily: fnt.ui, fontSize: 13,
letterSpacing: font === 'sans' ? '0.05em' : '0.12em',
transition:'background 600ms ease-in-out'}}>
{font === 'sans' ? '开始使用' : 'Start Reading'}
</div>
<div style={{fontFamily: mono, fontSize: 11, color: pal.sub,
letterSpacing:'0.2em',
transition:'color 600ms ease-in-out'}}>
FREE · BETA
</div>
</div>
</div>
{/* Right image block */}
<div style={{background: pal.bg,
border: `1px solid ${pal.line}`,
transition:'all 600ms ease-in-out',
aspectRatio: '3 / 4', position:'relative', overflow:'hidden'}}>
{/* Abstract book spine illustration */}
<svg width="100%" height="100%" viewBox="0 0 300 400"
preserveAspectRatio="xMidYMid slice">
{[0,1,2,3,4,5].map(i => (
<rect key={i} x={40 + i * 35} y={60 + (i % 2) * 20}
width={26} height={280 - (i % 3) * 30}
fill="none" stroke={pal.accent}
strokeWidth={i === 2 ? 2 : 1}
opacity={0.55 + (i === 2 ? 0.4 : 0)}
style={{transition:'stroke 600ms ease-in-out'}} />
))}
<circle cx={150} cy={200} r={58} fill="none"
stroke={pal.accent} strokeWidth={1.5} opacity={0.5}
style={{transition:'stroke 600ms ease-in-out'}} />
<line x1={40} y1={350} x2={260} y2={350}
stroke={pal.text} strokeWidth={0.8} opacity={0.5}
style={{transition:'stroke 600ms ease-in-out'}} />
</svg>
<div style={{position:'absolute', bottom: 16, left: 18,
fontFamily: mono, fontSize: 9, letterSpacing:'0.2em',
color: pal.sub,
transition:'color 600ms ease-in-out'}}>
FIG. 01 — SHELF
</div>
</div>
</div>
{/* Footer meta */}
<div style={{padding:'14px 32px', borderTop:`1px solid ${pal.line}`,
display:'flex', justifyContent:'space-between',
fontFamily: mono, fontSize: 10, letterSpacing:'0.2em',
color: pal.sub,
transition:'all 600ms ease-in-out'}}>
<span>DENSITY · {density}</span>
<span>FONT · {font.toUpperCase()}</span>
<span>TWEAK ID · #{palette}-{font}-{density}</span>
</div>
</div>
);
}
// ── Scene 3: Code view (12 – 17s) ─────────────────────────
function Scene3_CodeView() {
const { elapsed } = useSprite();
const introOp = interpolate(elapsed, [0, 0.5], [0, 1]);
const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
// Code typing effect
const fullCode = `// Tweaks via localStorage + CSS vars
const tweaks = {
palette: 'warm', // ← user 选
font: 'serif',
density: 40,
};
document.documentElement.style
.setProperty(
'--accent',
PALETTES[tweaks.palette].accent
);
localStorage.setItem(
'tweaks', JSON.stringify(tweaks)
);`;
const typeProgress = Math.max(0, Math.min(1, (elapsed - 0.6) / 2.4));
const visibleChars = Math.floor(fullCode.length * typeProgress);
const visibleCode = fullCode.slice(0, visibleChars);
const cursorBlink = Math.floor(elapsed * 2.5) % 2 === 0 && typeProgress < 1;
return (
<div style={{position:'absolute', inset:0, background:CREAM,
display:'flex', flexDirection:'column', opacity: introOp * fadeOut,
padding:'60px 80px'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 36}}>
<div>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.35em',
color: TERRA, marginBottom: 6}}>
UNDER THE HOOD
</div>
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500,
color: INK, letterSpacing:'-0.01em'}}>
原理 · <span style={{fontStyle:'italic', color: TERRA}}>一行配置</span>,无限变体
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign:'right', lineHeight: 1.5}}>
纯前端 · 无后端依赖<br/>
<span style={{fontSize: 14}}>刷新保留状态</span>
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1.4fr', gap: 40,
flex: 1}}>
{/* Left: simplified tweak visualization */}
<div style={{background:'#fff', border:`1px solid ${LINE}`,
padding: 36, display:'flex', flexDirection:'column', gap: 28}}>
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
color: TERRA}}>TWEAK · STATE</div>
<MiniRow label="palette" value="warm" swatch={TERRA} />
<MiniRow label="font" value="serif" />
<MiniRow label="density" value="40" />
<div style={{height: 1, background: LINE, margin:'8px 0'}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 16,
color: ASH, lineHeight: 1.55}}>
三个参数的组合空间:<br/>
<span style={{color: TERRA, fontFamily: mono, fontStyle:'normal',
fontSize: 14}}>3 × 3 × ∞ = 无限</span>
</div>
<div style={{flex: 1}} />
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.15em', lineHeight: 1.7}}>
→ 改代码:不必要<br/>
→ 重新生成:不必要<br/>
→ 只改变量:30ms 生效
</div>
</div>
{/* Right: code block */}
<div style={{background:'#0e1016', padding:'28px 32px',
fontFamily: mono, fontSize: 15, color:'#d4c9b5',
lineHeight: 1.7, position:'relative', overflow:'hidden'}}>
<div style={{display:'flex', gap: 8, marginBottom: 20}}>
<div style={{width: 10, height: 10, borderRadius:'50%',
background:'#ff5f57'}} />
<div style={{width: 10, height: 10, borderRadius:'50%',
background:'#febc2e'}} />
<div style={{width: 10, height: 10, borderRadius:'50%',
background:'#28c840'}} />
<div style={{marginLeft: 14, fontSize: 10, color:'#888',
letterSpacing:'0.15em'}}>tweaks.js</div>
</div>
<pre style={{whiteSpace:'pre-wrap', margin: 0,
fontFamily: mono, fontSize: 15, lineHeight: 1.65}}>
<CodeColorize text={visibleCode} />
{cursorBlink && <span style={{color:'#ff6a3d'}}>▌</span>}
</pre>
</div>
</div>
</div>
);
}
function MiniRow({ label, value, swatch }) {
return (
<div style={{display:'flex', alignItems:'center', gap: 14}}>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.2em', width: 80}}>{label}</div>
<div style={{flex: 1, fontFamily: mono, fontSize: 14, color: INK}}>
{value}
</div>
{swatch && (
<div style={{width: 14, height: 14, background: swatch}} />
)}
</div>
);
}
// Very light syntax coloring
function CodeColorize({ text }) {
const lines = text.split('\n');
return (
<>
{lines.map((line, i) => (
<span key={i}>
{colorizeLine(line)}
{'\n'}
</span>
))}
</>
);
}
function colorizeLine(line) {
const parts = [];
let rest = line;
// comment
const cIdx = rest.indexOf('//');
if (cIdx >= 0) {
const before = rest.slice(0, cIdx);
const comment = rest.slice(cIdx);
return (
<>
{tokenize(before)}
<span style={{color:'#6a7d6a'}}>{comment}</span>
</>
);
}
return tokenize(line);
}
function tokenize(s) {
// keywords + strings
const kw = ['const', 'let', 'var', 'function', 'return'];
const words = s.split(/(\s+|[{}();,=.:'])/);
return words.map((w, i) => {
if (kw.includes(w)) return <span key={i} style={{color:'#c79cff'}}>{w}</span>;
if (/^'[^']*'$/.test(w)) return <span key={i} style={{color:'#ffb86c'}}>{w}</span>;
if (/^[0-9]+$/.test(w)) return <span key={i} style={{color:'#ff6a3d'}}>{w}</span>;
if (['palette', 'font', 'density', 'tweaks', 'PALETTES', 'accent'].includes(w)) {
return <span key={i} style={{color:'#8be9fd'}}>{w}</span>;
}
if (['document', 'localStorage'].includes(w)) {
return <span key={i} style={{color:'#ff79c6'}}>{w}</span>;
}
return <span key={i}>{w}</span>;
});
}
// ── Scene 4: Finale (17 – 20s) ────────────────────────────
function Scene4_Final() {
const { elapsed } = useSprite();
const labelOp = interpolate(elapsed, [0.1, 0.7], [0, 1]);
const mainY = interpolate(elapsed, [0.2, 1.2], [30, 0], Easing.easeOut);
const mainOp = interpolate(elapsed, [0.2, 1.0], [0, 1]);
const lineW = interpolate(elapsed, [1.0, 1.8], [0, 540]);
const dimsOp = interpolate(elapsed, [1.3, 2.1], [0, 1]);
const dimensions = ['配色', '字型', '密度', '布局', '动画速度'];
return (
<div style={{position:'absolute', inset:0, background:CREAM,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 26, opacity: labelOp}}>
TWEAKS · EVERYTHING IS A VARIABLE
</div>
<div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
color: INK, lineHeight: 1.05, letterSpacing:'-0.01em',
opacity: mainOp, transform: `translateY(${mainY}px)`,
textAlign:'center'}}>
一个源文件 · <span style={{fontStyle:'italic', color: TERRA}}>无限</span>变体
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 38}} />
<div style={{marginTop: 36, display:'flex', gap: 10,
opacity: dimsOp, alignItems:'center'}}>
{dimensions.map((d, i) => (
<React.Fragment key={i}>
{i > 0 && (
<span style={{fontFamily: mono, fontSize: 14,
color: LINE, margin:'0 2px'}}>·</span>
)}
<span style={{fontFamily: mono, fontSize: 14,
letterSpacing:'0.2em',
color: i === 0 ? TERRA : ASH,
padding:'6px 14px',
border: `1px solid ${i === 0 ? TERRA : LINE}`}}>
{d}
</span>
</React.Fragment>
))}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20,
color: ASH, marginTop: 36, opacity: dimsOp,
maxWidth: 720, textAlign:'center', lineHeight: 1.5}}>
设计不是一次性的结果 ——<br/>
而是一组可以随时拨动的旋钮
</div>
</div>
);
}
// ── Watermark ─────────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
// ── Main composition ──────────────────────────────────────
function App() {
return (
<Stage duration={20} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
<Sprite start={3} end={12}><Scene2_MainStage /></Sprite>
<Sprite start={12} end={17}><Scene3_CodeView /></Sprite>
<Sprite start={17} end={20}><Scene4_Final /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+767
View File
@@ -0,0 +1,767 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Infographic Demo</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: #0c0c0c;
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [a, b] = input, [x, y] = output;
if (t <= a) return x; if (t >= b) return y;
let p = (t - a) / (b - a); if (easing) p = easing(p);
return x + (y - x) * p;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
const update = () => {
const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
setScale(s);
};
update(); window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false, last = null;
function tick(now) {
if (cancelled) return;
if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
const delta = (now - last) / 1000; last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
{children}
</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
return (
<SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const OLIVE = '#6a6b4e';
const DEEP_BLUE = '#2a3552';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── Scene 1: Title (0 – 3s) ───────────────────────────────
function Scene1_Title() {
const { elapsed } = useSprite();
const topOp = interpolate(elapsed, [0.0, 0.6], [0, 1]);
const topLineW = interpolate(elapsed, [0.3, 1.0], [0, 220]);
const mainOp = interpolate(elapsed, [0.5, 1.2], [0, 1]);
const mainY = interpolate(elapsed, [0.5, 1.3], [32, 0], Easing.easeOut);
const italicOp = interpolate(elapsed, [1.0, 1.6], [0, 1]);
const subOp = interpolate(elapsed, [1.5, 2.1], [0, 1]);
const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0], Easing.easeIn);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{display:'flex', alignItems:'center', gap: 18, opacity: topOp, marginBottom: 48}}>
<div style={{height: 1, background: TERRA, width: topLineW}}/>
<div style={{fontFamily: mono, fontSize: 12, color: TERRA,
letterSpacing:'0.35em'}}>
信息图 · 数据驱动 · 印刷级
</div>
<div style={{height: 1, background: TERRA, width: topLineW}}/>
</div>
<div style={{fontFamily: serif, fontSize: 160, fontWeight: 500,
color: INK, lineHeight: 1, letterSpacing: '-0.02em',
opacity: mainOp, transform: `translateY(${mainY}px)`}}>
让数据<span style={{fontStyle:'italic', color: TERRA, opacity: italicOp}}>说话</span>
</div>
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 24,
color: ASH, marginTop: 44, opacity: subOp, letterSpacing: '0.02em'}}>
精确排版 · 一眼看懂 · 可印刷
</div>
</div>
);
}
// ── Scene 2: Full infographic layout (3 – 10s) ────────────
// Uses a magazine spread style: headline, three columns (big numbers / bars / pie), trend line footer
function Scene2_Spread() {
const { elapsed } = useSprite();
const headerOp = interpolate(elapsed, [0, 0.5], [0, 1]);
const ruleW = interpolate(elapsed, [0.3, 1.0], [0, 1800]);
const colDelay = [0.6, 1.0, 1.4];
return (
<div style={{position:'absolute', inset:0, background: CREAM,
padding: '60px 80px 50px', display:'flex', flexDirection:'column'}}>
{/* Masthead */}
<div style={{opacity: headerOp, display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 14, fontFamily: mono, fontSize: 11,
letterSpacing: '0.3em', color: ASH}}>
<span>HUASHU · INFOGRAPHIC REPORT</span>
<span>VOL. 01 · 2026.04</span>
</div>
{/* Headline */}
<div style={{opacity: headerOp, fontFamily: serif, fontSize: 72,
fontWeight: 500, color: INK, lineHeight: 1.05, letterSpacing: '-0.01em',
marginBottom: 8}}>
2026 AI 写作工具
<span style={{fontStyle:'italic', color: TERRA, marginLeft: 20}}>年度观察</span>
</div>
<div style={{opacity: headerOp, fontFamily: serif, fontStyle:'italic',
fontSize: 20, color: ASH, marginBottom: 22}}>
156 位创作者匿名问卷 · 3 月 15 日 – 4 月 10 日
</div>
{/* Top rule */}
<div style={{width: ruleW, height: 1, background: INK, marginBottom: 28}}/>
{/* Three-column grid */}
<div style={{display:'grid', gridTemplateColumns:'1fr 1px 1.15fr 1px 0.95fr',
gap: 36, flex: 1}}>
<ColumnLeft elapsed={elapsed - colDelay[0]} />
<div style={{background: LINE}}/>
<ColumnMid elapsed={elapsed - colDelay[1]} />
<div style={{background: LINE}}/>
<ColumnRight elapsed={elapsed - colDelay[2]} />
</div>
{/* Footer trend line */}
<FooterTrend elapsed={elapsed - 3.5} />
</div>
);
}
function ColumnLeft({ elapsed }) {
const e = Math.max(0, elapsed);
const labelOp = interpolate(e, [0, 0.4], [0, 1]);
// 87%
const n1 = Math.round(interpolate(e, [0.3, 1.8], [0, 87], Easing.easeOut));
const bar1 = interpolate(e, [0.3, 1.8], [0, 87], Easing.easeOut);
// 3.2x
const n2 = interpolate(e, [1.3, 2.8], [1.0, 3.2], Easing.easeOut);
const bar2 = interpolate(e, [1.3, 2.8], [0, 3.2/5*100], Easing.easeOut);
// 156
const n3 = Math.round(interpolate(e, [2.3, 3.6], [0, 156], Easing.easeOut));
const bar3 = interpolate(e, [2.3, 3.6], [0, 100], Easing.easeOut);
return (
<div style={{display:'flex', flexDirection:'column', gap: 30}}>
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
color: TERRA, opacity: labelOp}}>
COLUMN / 01 · 核心指标
</div>
<MetricRow
value={`${n1}%`}
width={`${bar1}%`}
label="用户每周使用 AI 辅助写作"
note="¹ 每周 ≥ 3 次"
color={TERRA}
/>
<MetricRow
value={`${n2.toFixed(1)}×`}
width={`${bar2}%`}
label="平均产出效率提升"
note="² 自述周稿字数"
color={OLIVE}
/>
<MetricRow
value={String(n3)}
width={`${bar3}%`}
label="有效样本数"
note="³ 剔除 AI 默认答卷"
color={DEEP_BLUE}
/>
</div>
);
}
function MetricRow({ value, width, label, note, color }) {
return (
<div>
<div style={{display:'flex', alignItems:'baseline', gap: 10, marginBottom: 8}}>
<div style={{fontFamily: serif, fontSize: 72, fontWeight: 500,
color: INK, lineHeight: 0.95, letterSpacing:'-0.02em',
fontVariantNumeric:'tabular-nums'}}>
{value}
</div>
</div>
<div style={{height: 6, background: '#eee7d7', width:'100%',
marginBottom: 10, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%',
width, background: color}}/>
</div>
<div style={{fontFamily: serif, fontSize: 15, color: INK, lineHeight: 1.4}}>
{label}
<span style={{fontFamily: mono, fontSize: 9, color: ASH,
verticalAlign:'super', marginLeft: 4}}>{note}</span>
</div>
</div>
);
}
function ColumnMid({ elapsed }) {
const e = Math.max(0, elapsed);
const labelOp = interpolate(e, [0, 0.4], [0, 1]);
// 5 bars, staggered 0.15s
const bars = [
{ name:'长文创作', pct: 78, color: TERRA },
{ name:'短内容', pct: 64, color: OLIVE },
{ name:'标题/文案', pct: 52, color: DEEP_BLUE },
{ name:'润色校对', pct: 41, color: ASH },
{ name:'翻译', pct: 29, color: ASH },
];
const chartH = 320;
const maxPct = 100;
return (
<div style={{display:'flex', flexDirection:'column', gap: 18}}>
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
color: TERRA, opacity: labelOp}}>
COLUMN / 02 · 用途分布
</div>
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500,
color: INK, lineHeight: 1.2, opacity: labelOp,
letterSpacing: '-0.01em'}}>
你最常用 AI 做什么?
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
color: ASH, opacity: labelOp}}>
多选题 · 百分比占全样本
</div>
{/* chart */}
<div style={{position:'relative', height: chartH, display:'flex',
alignItems:'flex-end', gap: 18, padding: '0 8px', marginTop: 4,
borderBottom:`1px solid ${INK}`}}>
{/* y-axis gridlines */}
{[25, 50, 75, 100].map(v => (
<div key={v} style={{position:'absolute', left: 0, right: 0,
bottom: (v/maxPct)*chartH, height: 1,
borderTop:`1px dashed ${LINE}`, pointerEvents:'none'}}>
<div style={{position:'absolute', left: -36, top: -8,
fontFamily: mono, fontSize: 9, color: ASH,
letterSpacing:'0.05em'}}>
{v}%
</div>
</div>
))}
{bars.map((b, i) => {
const delay = 0.8 + i * 0.15;
const growT = Math.max(0, Math.min(1, (e - delay) / 0.55));
const h = growT * (b.pct / maxPct) * chartH;
const labelOpB = Math.max(0, Math.min(1, (e - delay - 0.35) / 0.3));
return (
<div key={i} style={{flex: 1, position:'relative',
display:'flex', flexDirection:'column', alignItems:'center',
justifyContent:'flex-end', height:'100%'}}>
<div style={{position:'absolute', top: -22,
fontFamily: mono, fontSize: 11, color: INK,
letterSpacing:'0.02em', fontVariantNumeric:'tabular-nums',
opacity: labelOpB}}>
{b.pct}%
</div>
<div style={{width: '100%', height: h, background: b.color,
transition:'none'}}/>
</div>
);
})}
</div>
{/* x-axis labels */}
<div style={{display:'flex', gap: 18, padding: '0 8px', marginTop: -8}}>
{bars.map((b, i) => (
<div key={i} style={{flex: 1, textAlign:'center',
fontFamily: serif, fontSize: 12, color: INK,
letterSpacing:'0.02em', opacity: labelOp}}>
{b.name}
</div>
))}
</div>
</div>
);
}
function ColumnRight({ elapsed }) {
const e = Math.max(0, elapsed);
const labelOp = interpolate(e, [0, 0.4], [0, 1]);
// Three pie slices sweep in
const slices = [
{ label:'Claude', pct: 46, color: TERRA },
{ label:'GPT', pct: 31, color: DEEP_BLUE },
{ label:'GLM/国产', pct: 23, color: OLIVE },
];
const cx = 130, cy = 130, r = 104;
const C = 2 * Math.PI * r;
// cumulative pct as fractions
let acc = 0;
const slicesCalc = slices.map((s, i) => {
const delay = 0.6 + i * 0.45;
const sweepT = Math.max(0, Math.min(1, (e - delay) / 0.7));
const start = acc;
const end = acc + s.pct / 100;
acc = end;
return { ...s, start, end, sweepT, delay };
});
return (
<div style={{display:'flex', flexDirection:'column', gap: 14}}>
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
color: TERRA, opacity: labelOp}}>
COLUMN / 03 · 模型占有率
</div>
<div style={{fontFamily: serif, fontSize: 24, fontWeight: 500,
color: INK, lineHeight: 1.2, opacity: labelOp,
letterSpacing:'-0.01em'}}>
主力模型分布
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 12,
color: ASH, opacity: labelOp, marginBottom: 6}}>
单选题 · 日常首选
</div>
<div style={{display:'flex', alignItems:'center', gap: 18}}>
<svg width="260" height="260" viewBox="0 0 260 260">
{/* Background ring */}
<circle cx={cx} cy={cy} r={r} fill="none"
stroke={LINE} strokeWidth={1}/>
{slicesCalc.map((s, i) => {
// Draw partial arc with stroke-dasharray
const sweepLen = (s.end - s.start) * s.sweepT;
const dash = sweepLen * C;
const gap = C - dash;
const rot = s.start * 360 - 90;
return (
<circle key={i} cx={cx} cy={cy} r={r}
fill="none" stroke={s.color} strokeWidth={28}
strokeDasharray={`${dash} ${gap}`}
strokeDashoffset={0}
transform={`rotate(${rot} ${cx} ${cy})`}
opacity={0.95}/>
);
})}
{/* Inner text */}
<text x={cx} y={cy - 4} textAnchor="middle"
fontFamily={serif} fontSize={34} fill={INK}
fontWeight={500} letterSpacing="-0.5">
n=156
</text>
<text x={cx} y={cy + 22} textAnchor="middle"
fontFamily={mono} fontSize={10} fill={ASH}
letterSpacing="0.2em">
TOTAL
</text>
</svg>
<div style={{display:'flex', flexDirection:'column', gap: 14, flex: 1}}>
{slicesCalc.map((s, i) => {
const txtOp = Math.max(0, Math.min(1, (e - s.delay - 0.3) / 0.4));
return (
<div key={i} style={{display:'flex', alignItems:'baseline',
gap: 10, opacity: txtOp}}>
<div style={{width: 10, height: 10, background: s.color,
marginTop: 4, flexShrink: 0}}/>
<div style={{flex: 1}}>
<div style={{fontFamily: serif, fontSize: 18,
fontWeight: 500, color: INK, letterSpacing:'0.01em'}}>
{s.label}
</div>
</div>
<div style={{fontFamily: serif, fontSize: 22,
fontWeight: 500, color: INK,
fontVariantNumeric:'tabular-nums'}}>
{s.pct}%
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
function FooterTrend({ elapsed }) {
const e = Math.max(0, elapsed);
const op = interpolate(e, [0, 0.5], [0, 1]);
const data = [12, 18, 24, 31, 38, 48, 57, 64, 71, 78, 84, 87];
const months = ['05','06','07','08','09','10','11','12','01','02','03','04'];
const W = 1760, H = 86, PAD = 8;
const maxV = 100;
// progressive reveal of line
const revealT = Math.max(0, Math.min(1, (e - 0.3) / 1.4));
const nPoints = Math.max(1, Math.floor(revealT * data.length));
const pts = [];
for (let i = 0; i < data.length; i++) {
const x = (i / (data.length - 1)) * W;
const y = H - (data[i] / maxV) * (H - PAD * 2) - PAD;
pts.push([x, y]);
}
const visiblePts = pts.slice(0, nPoints);
const d = visiblePts.map((p, i) => (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
const area = visiblePts.length > 1
? d + ` L ${visiblePts[visiblePts.length-1][0].toFixed(1)} ${H} L 0 ${H} Z`
: '';
return (
<div style={{marginTop: 26, opacity: op}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 8}}>
<div style={{fontFamily: mono, fontSize: 10, color: TERRA,
letterSpacing:'0.3em'}}>TREND · 过去 12 个月 AI 周使用率(%)</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
color: ASH}}>
从 12% 到 87% · 增长 7.25×
</div>
</div>
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}
style={{display:'block', width:'100%', height: H}}>
{area && <path d={area} fill={TERRA} opacity={0.08}/>}
{d && <path d={d} fill="none" stroke={TERRA} strokeWidth={1.6}/>}
{visiblePts.map((p, i) => (
<circle key={i} cx={p[0]} cy={p[1]} r={2.4} fill={TERRA}/>
))}
{/* Axis labels */}
{pts.map((p, i) => (
<text key={i} x={p[0]} y={H - 0}
textAnchor="middle" fontFamily={mono} fontSize={9} fill={ASH}
opacity={0.6}>
{months[i]}
</text>
))}
</svg>
</div>
);
}
// ── Scene 3: Typography close-up (10 – 17s) ───────────────
function Scene3_Typography() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1]);
const fadeOut = interpolate(elapsed, [6.5, 7.0], [1, 0], Easing.easeIn);
const opacity = Math.min(fadeIn, fadeOut);
const labelOp = interpolate(elapsed, [0.2, 0.8], [0, 1]);
const leftOp = interpolate(elapsed, [0.4, 1.2], [0, 1]);
const compareOp = interpolate(elapsed, [1.8, 2.6], [0, 1]);
const captionOp = interpolate(elapsed, [3.6, 4.4], [0, 1]);
// Pulsing scale on "87"
const pulse = 1 + Math.sin(elapsed * 1.6) * 0.008;
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity,
padding: '60px 80px', display:'flex', flexDirection:'column'}}>
{/* Top label */}
<div style={{display:'flex', alignItems:'baseline',
justifyContent:'space-between', marginBottom: 32, opacity: labelOp}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>DETAIL · ZOOM 1.5×</div>
<div style={{fontFamily: serif, fontSize: 46, fontWeight: 500,
color: INK, letterSpacing:'-0.01em'}}>
排版细节:<span style={{fontStyle:'italic', color: TERRA}}>品味税</span>
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign:'right', maxWidth: 400, lineHeight: 1.5}}>
"AI 能写中文,但分不清什么是好的中文排版"
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'1.25fr 1fr',
gap: 56, flex: 1}}>
{/* Left: Number gradient showcase */}
<div style={{opacity: leftOp, display:'flex', flexDirection:'column',
gap: 28}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.3em'}}>01 · 字号梯度 · HIERARCHY</div>
<div style={{display:'flex', alignItems:'baseline', gap: 36,
borderBottom:`1px solid ${LINE}`, paddingBottom: 28}}>
<div style={{fontFamily: serif, fontSize: 220, fontWeight: 500,
color: INK, lineHeight: 0.88, letterSpacing:'-0.04em',
fontVariantNumeric:'tabular-nums', display:'inline-block',
transform:`scale(${pulse})`, transformOrigin:'left bottom'}}>
87<span style={{fontSize: 80, color: TERRA,
verticalAlign:'super', marginLeft: 4, fontStyle:'italic'}}>%</span>
</div>
<div style={{fontFamily: serif, fontSize: 110, fontWeight: 400,
color: OLIVE, lineHeight: 0.88, letterSpacing:'-0.02em',
fontVariantNumeric:'tabular-nums'}}>
3.2<span style={{fontSize: 44, fontStyle:'italic',
color: ASH, marginLeft: 2}}>×</span>
</div>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 400,
color: DEEP_BLUE, lineHeight: 0.88,
fontVariantNumeric:'tabular-nums'}}>
156
</div>
</div>
<div style={{fontFamily: serif, fontSize: 15, color: ASH,
lineHeight: 1.55, maxWidth: 580}}>
主数据 <span style={{color: INK, fontWeight: 500}}>220pt</span>、
次级 <span style={{color: INK, fontWeight: 500}}>110pt</span>、
辅助 <span style={{color: INK, fontWeight: 500}}>56pt</span>——
梯度 2× 不是工程师拍脑袋,是几百年印刷品的视觉惯性。
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.3em', marginTop: 10}}>02 · 换行 · TEXT-WRAP: PRETTY</div>
<div style={{fontFamily: serif, fontSize: 34, fontWeight: 500,
color: INK, lineHeight: 1.25, letterSpacing:'-0.01em',
maxWidth: 620, textWrap:'pretty'}}>
标题在该断的地方断开<br/>
避免孤字和单字成行
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.3em', marginTop: 4}}>03 · 上标辅注 · MONO FOOTNOTE</div>
<div style={{fontFamily: serif, fontSize: 20, color: INK,
lineHeight: 1.6, maxWidth: 620}}>
87%
<span style={{fontFamily: mono, fontSize: 11, color: TERRA,
verticalAlign:'super', marginLeft: 4}}>¹</span>
用户每周用 AI 辅助写作
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
marginTop: 10, letterSpacing:'0.05em'}}>
¹ 基于 156 位创作者调研,每周 ≥ 3 次
</div>
</div>
</div>
{/* Right: AI slop vs 精致 */}
<div style={{opacity: compareOp, display:'flex', flexDirection:'column',
gap: 20}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.3em'}}>04 · AI SLOP vs 精致版</div>
{/* Slop version */}
<div style={{position:'relative', border: `1.5px dashed #c06060`,
padding: '22px 22px', borderRadius: 16,
background: 'linear-gradient(135deg, #6a47d4 0%, #3a1a7a 100%)'}}>
<div style={{position:'absolute', top: -10, left: 14,
background: '#c06060', color:'#fff', fontFamily: mono,
fontSize: 9, padding:'2px 10px', letterSpacing:'0.2em'}}>
✕ 反例 · 不要这样做
</div>
<div style={{fontFamily: sans, fontSize: 28, fontWeight: 700,
color:'#fff', marginBottom: 6, letterSpacing:'-0.01em'}}>
🚀 AI 写作工具爆发增长!
</div>
<div style={{fontFamily: sans, fontSize: 13, color:'rgba(255,255,255,0.85)',
lineHeight: 1.5}}>
✨ 87% 用户都在用!💡 效率提升 3.2 倍!🎯 赶紧加入!
</div>
<div style={{marginTop: 14, display:'flex', gap: 8}}>
<div style={{background:'rgba(255,255,255,0.2)',
padding:'6px 12px', borderRadius: 999,
fontFamily: sans, fontSize: 11, color:'#fff'}}>
#AI写作
</div>
<div style={{background:'rgba(255,255,255,0.2)',
padding:'6px 12px', borderRadius: 999,
fontFamily: sans, fontSize: 11, color:'#fff'}}>
#爆款
</div>
</div>
</div>
{/* Good version */}
<div style={{position:'relative', background:'#fff',
border: `1px solid ${LINE}`, padding: '22px 22px'}}>
<div style={{position:'absolute', top: -10, left: 14,
background: TERRA, color:'#fff', fontFamily: mono,
fontSize: 9, padding:'2px 10px', letterSpacing:'0.2em'}}>
✓ 精致版 · DO THIS
</div>
<div style={{fontFamily: mono, fontSize: 9, color: TERRA,
letterSpacing:'0.3em', marginBottom: 4}}>ESSAY · 2026.04</div>
<div style={{fontFamily: serif, fontSize: 26, fontWeight: 500,
color: INK, lineHeight: 1.2, letterSpacing:'-0.01em',
marginBottom: 8}}>
AI 写作<br/>
<span style={{fontStyle:'italic'}}>悄然</span>改变创作者
</div>
<div style={{height: 1, background: INK, width: 70, marginBottom: 10}}/>
<div style={{fontFamily: serif, fontSize: 13, color:'#444',
lineHeight: 1.6}}>
87% 的创作者已经把 AI 纳入日常工作流;
效率提升 3.2×,但人味不减反增——
工具不定义内容,品味才定义。
</div>
</div>
</div>
</div>
{/* Caption */}
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20,
color: ASH, textAlign:'center', marginTop: 22, opacity: captionOp,
letterSpacing:'0.02em'}}>
排版细节是 AI 分不清的 <span style={{color: TERRA, fontWeight: 500,
fontStyle:'normal'}}>品味税</span>
</div>
</div>
);
}
// ── Scene 4: Outro (17 – 22s) ─────────────────────────────
function Scene4_Outro() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1]);
const mainY = interpolate(elapsed, [0, 1.2], [28, 0], Easing.easeOut);
const italicOp = interpolate(elapsed, [0.8, 1.4], [0, 1]);
const lineW = interpolate(elapsed, [1.0, 1.8], [0, 680]);
const subOp = interpolate(elapsed, [1.6, 2.2], [0, 1]);
const monoOp = interpolate(elapsed, [2.4, 3.2], [0, 1]);
const monoLineW = interpolate(elapsed, [2.8, 3.8], [0, 520]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 32, opacity: fadeIn}}>
HUASHU-DESIGN · INFOGRAPHIC CAPABILITY
</div>
<div style={{fontFamily: serif, fontSize: 148, fontWeight: 500,
color: INK, lineHeight: 1, letterSpacing:'-0.02em',
transform:`translateY(${mainY}px)`}}>
<span style={{fontStyle:'italic', opacity: italicOp}}>数据</span>
<span style={{opacity: fadeIn}}> 配得上 </span>
<span style={{color: TERRA, opacity: italicOp}}>好看</span>
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 44}}/>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
color: ASH, marginTop: 26, opacity: subOp, letterSpacing:'0.02em'}}>
印刷级 · 不因为缩放而失真
</div>
<div style={{marginTop: 60, opacity: monoOp,
display:'flex', alignItems:'center', flexDirection:'column', gap: 14}}>
<div style={{height: 1, background: LINE, width: monoLineW}}/>
<div style={{fontFamily: mono, fontSize: 14, color: INK,
letterSpacing:'0.18em'}}>
export → <span style={{color: TERRA}}>PDF 矢量</span> /
<span style={{color: OLIVE}}> PNG 300dpi</span> /
<span style={{color: DEEP_BLUE}}> SVG 原生</span>
</div>
<div style={{height: 1, background: LINE, width: monoLineW}}/>
</div>
</div>
);
}
// ── Watermark ─────────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
// ── Composition ───────────────────────────────────────────
function App() {
return (
<Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
<Sprite start={3} end={10}><Scene2_Spread /></Sprite>
<Sprite start={10} end={17}><Scene3_Typography /></Sprite>
<Sprite start={17} end={22}><Scene4_Outro /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+652
View File
@@ -0,0 +1,652 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Expert Review</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [a, b] = input, [x, y] = output;
if (t <= a) return x; if (t >= b) return y;
let p = (t - a) / (b - a); if (easing) p = easing(p);
return x + (y - x) * p;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
const update = () => {
const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
setScale(s);
};
update(); window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false, last = null;
function tick(now) {
if (cancelled) return;
if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
const delta = (now - last) / 1000; last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
{children}
</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
return (
<SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const OLIVE = '#6a6b4e';
const DEEP_BLUE = '#2a3552';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── 5 dimensions ──────────────────────────────────────────
const DIMENSIONS = [
{ no: '01', name: '哲学一致性', desc: '是否遵循既定的设计风格', score: 9 },
{ no: '02', name: '视觉层级', desc: '信息优先级是否一目了然', score: 8 },
{ no: '03', name: '细节执行', desc: '排版、间距、字重是否到位', score: 7 },
{ no: '04', name: '功能性', desc: '交互是否顺畅、可用', score: 6 },
{ no: '05', name: '创新性', desc: '是否超出了平均水准', score: 8 },
];
const COMMENTS = [
'赤陶橙贯穿,serif + 留白很 Kenya Hara',
'Hero 和 body 强度接近,主次需再拉开',
'行距、字号梯度还差一点点克制',
'CTA 可达但色值和主色冲突',
'版面节奏有想法,避开了模板感',
];
// ── Scene 1: Title (0 – 3s) ────────────────────────────
function Scene1_Title() {
const { elapsed } = useSprite();
const topOp = interpolate(elapsed, [0, 0.5], [0, 1]);
const titleY = interpolate(elapsed, [0.2, 1.3], [50, 0], Easing.easeOut);
const titleOp = interpolate(elapsed, [0.2, 1.1], [0, 1]);
const lineW = interpolate(elapsed, [1.0, 1.8], [0, 540]);
const subOp = interpolate(elapsed, [1.4, 2.1], [0, 1]);
const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 28, opacity: topOp}}>
设计评审 · 5 维度评分
</div>
<div style={{fontFamily: serif, fontSize: 120, fontWeight: 500, color: INK,
lineHeight: 1, letterSpacing:'-0.02em', opacity: titleOp,
transform: `translateY(${titleY}px)`}}>
<span style={{fontStyle:'italic', color: TERRA}}>评</span>设计 · 不评设计师
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
marginTop: 28, opacity: subOp, letterSpacing:'0.02em'}}>
做完之后 · 用 5 个刻度看清楚
</div>
</div>
);
}
// ── Scene 2: 5 dimensions intro (3 – 8s) ───────────────
function Scene2_Dimensions() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const fadeOut = interpolate(elapsed, [4.5, 5.0], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '100px 100px 80px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: titleOp, marginBottom: 60,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>步骤 1 / 3 · 评审维度</div>
<div style={{fontFamily: serif, fontSize: 60, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
五把<span style={{fontStyle:'italic', color: TERRA}}>尺子</span>
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 19, color: ASH,
textAlign:'right', lineHeight: 1.5}}>
主观审美变不可辩论,<br/>
客观维度变可打分
</div>
</div>
<div style={{flex: 1, display:'grid', gridTemplateColumns:'repeat(5, 1fr)',
gap: 22, alignItems:'stretch'}}>
{DIMENSIONS.map((d, i) => {
const appearStart = 0.6 + i * 0.4;
const appearEnd = appearStart + 0.7;
const op = interpolate(elapsed, [appearStart, appearEnd], [0, 1], Easing.easeOut);
const ty = interpolate(elapsed, [appearStart, appearEnd], [30, 0], Easing.easeOut);
return (
<div key={i} style={{
opacity: op,
transform: `translateY(${ty}px)`,
background: '#fff',
border: `1px solid ${LINE}`,
padding: '32px 26px 30px',
display:'flex', flexDirection:'column',
position:'relative',
}}>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 72,
fontWeight: 400, color: TERRA, lineHeight: 1,
letterSpacing:'-0.02em', marginBottom: 20}}>
{d.no}
</div>
<div style={{height: 1, background: INK, width: 40, marginBottom: 18}} />
<div style={{fontFamily: serif, fontSize: 26, fontWeight: 500,
color: INK, lineHeight: 1.15, marginBottom: 12}}>
{d.name}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
color: ASH, lineHeight: 1.55, flex: 1}}>
{d.desc}
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.2em', marginTop: 22}}>
0 – 10 PT
</div>
</div>
);
})}
</div>
</div>
);
}
// ── Scene 3: Radar + scoring (8 – 14s) ────────────────
function Scene3_Radar() {
const { elapsed } = useSprite();
const headerOp = interpolate(elapsed, [0, 0.5], [0, 1]);
const fadeOut = interpolate(elapsed, [5.5, 6.0], [1, 0]);
// Radar reveal progress — polygon expands from center
const reveal = interpolate(elapsed, [0.8, 2.4], [0, 1], Easing.easeOut);
// Total score count-up
const totalT = interpolate(elapsed, [1.6, 2.8], [0, 1], Easing.easeOut);
const totalVal = Math.round(totalT * 38);
// Radar geometry
const cx = 340, cy = 440, R = 260;
const N = 5;
const maxScore = 10;
const angle = i => -Math.PI/2 + i * 2 * Math.PI / N;
// Axis endpoints
const axisPts = DIMENSIONS.map((_, i) => ({
x: cx + Math.cos(angle(i)) * R,
y: cy + Math.sin(angle(i)) * R,
}));
// Score polygon points (animated)
const scorePts = DIMENSIONS.map((d, i) => {
const r = (d.score / maxScore) * R * reveal;
return {
x: cx + Math.cos(angle(i)) * r,
y: cy + Math.sin(angle(i)) * r,
};
});
const scorePath = scorePts.map(p => `${p.x},${p.y}`).join(' ');
// Concentric rings
const rings = [2, 4, 6, 8, 10];
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '70px 90px 50px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: headerOp, marginBottom: 24,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>步骤 2 / 3 · 打分</div>
<div style={{fontFamily: serif, fontSize: 54, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
五边形 · <span style={{fontStyle:'italic'}}>照见</span>每一维
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
textAlign:'right', lineHeight: 1.5}}>
不是给个评价,<br/>
是把问题「可视化」出来
</div>
</div>
<div style={{flex: 1, display:'grid', gridTemplateColumns: '720px 1fr', gap: 60}}>
{/* Radar */}
<div style={{position:'relative', background:'#fff', border:`1px solid ${LINE}`}}>
<svg viewBox="0 0 720 880" width="100%" height="100%" style={{display:'block'}}>
{/* Concentric rings */}
{rings.map((r, i) => {
const ringR = (r / maxScore) * R;
const pts = DIMENSIONS.map((_, k) => {
const x = cx + Math.cos(angle(k)) * ringR;
const y = cy + Math.sin(angle(k)) * ringR;
return `${x},${y}`;
}).join(' ');
return (
<g key={i}>
<polygon points={pts} fill="none" stroke={LINE} strokeWidth="1" />
<text x={cx + 6} y={cy - ringR + 4}
fontFamily={mono} fontSize="10" fill={ASH}
letterSpacing="0.1em">{r}</text>
</g>
);
})}
{/* Axes */}
{axisPts.map((p, i) => (
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y}
stroke={LINE} strokeWidth="1" />
))}
{/* Score polygon */}
<polygon points={scorePath}
fill={TERRA} fillOpacity="0.18"
stroke={TERRA} strokeWidth="2" />
{/* Score dots */}
{scorePts.map((p, i) => reveal > 0.6 && (
<circle key={i} cx={p.x} cy={p.y} r="5"
fill={TERRA} opacity={Math.min(1, (reveal - 0.6) / 0.4)} />
))}
{/* Axis labels + score */}
{DIMENSIONS.map((d, i) => {
const labelR = R + 48;
const lx = cx + Math.cos(angle(i)) * labelR;
const ly = cy + Math.sin(angle(i)) * labelR;
const anchor = Math.abs(Math.cos(angle(i))) < 0.2 ? 'middle'
: Math.cos(angle(i)) > 0 ? 'start' : 'end';
const showScore = elapsed > 2.4 + i * 0.15;
return (
<g key={i}>
<text x={lx} y={ly}
fontFamily={mono} fontSize="13" fill={INK}
fontWeight="500" textAnchor={anchor}
letterSpacing="0.08em">
{d.name}
</text>
{showScore && (
<text x={lx} y={ly + 20}
fontFamily={serif} fontSize="22" fill={TERRA}
fontStyle="italic" fontWeight="500" textAnchor={anchor}>
{d.score}
<tspan fontSize="13" fill={ASH} fontStyle="normal"> / 10</tspan>
</text>
)}
</g>
);
})}
{/* Center total score */}
<text x={cx} y={750}
fontFamily={mono} fontSize="11" fill={ASH}
letterSpacing="0.3em" textAnchor="middle">
总分
</text>
<text x={cx} y={820}
fontFamily={serif} fontSize="72" fill={INK}
fontWeight="500" textAnchor="middle"
letterSpacing="-0.02em">
<tspan fontStyle="italic" fill={TERRA}>{totalVal}</tspan>
<tspan fontSize="34" fill={ASH} letterSpacing="0"> / 50</tspan>
</text>
</svg>
</div>
{/* Right: breakdown list */}
<div style={{display:'flex', flexDirection:'column', gap: 18, paddingTop: 4}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.25em', marginBottom: 4}}>
BREAKDOWN · 逐项
</div>
{DIMENSIONS.map((d, i) => {
const rowAppear = 2.2 + i * 0.25;
const op = interpolate(elapsed, [rowAppear, rowAppear + 0.6], [0, 1]);
const barT = interpolate(elapsed, [rowAppear + 0.2, rowAppear + 0.9],
[0, d.score / 10], Easing.easeOut);
const tx = interpolate(elapsed, [rowAppear, rowAppear + 0.5], [20, 0], Easing.easeOut);
return (
<div key={i} style={{opacity: op, transform:`translateX(${tx}px)`,
background:'#fff', border:`1px solid ${LINE}`, padding:'14px 20px'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 8}}>
<div style={{fontFamily: serif, fontSize: 22, fontWeight: 500, color: INK}}>
<span style={{fontFamily: mono, fontSize: 11, color: TERRA,
marginRight: 12, letterSpacing:'0.15em'}}>{d.no}</span>
{d.name}
</div>
<div style={{fontFamily: serif, fontSize: 22, fontStyle:'italic',
fontWeight: 500, color: TERRA}}>
{d.score}<span style={{fontSize: 13, color: ASH, fontStyle:'normal'}}> / 10</span>
</div>
</div>
{/* Progress bar */}
<div style={{height: 4, background: LINE, position:'relative', marginBottom: 8}}>
<div style={{position:'absolute', top:0, left:0, height:'100%',
width: `${barT * 100}%`, background: TERRA}} />
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 14,
color: ASH, lineHeight: 1.5}}>
{COMMENTS[i]}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
// ── Scene 4: Keep / Fix / Quick Wins (14 – 20s) ──────
function Scene4_Actions() {
const { elapsed } = useSprite();
const headerOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const fadeOut = interpolate(elapsed, [5.5, 6.0], [1, 0]);
const keeps = [
'赤陶橙 accent 贯穿全文',
'serif display 给了文学气质',
'留白足够 · 信息不挤',
];
const fixes = [
{ tag: '致命', sev: TERRA, text: 'Hero 图和 body 抢焦点 · 降低 hero 字号' },
{ tag: '重要', sev: OLIVE, text: '侧边 CTA 色和品牌主色冲突' },
{ tag: '优化', sev: ASH, text: 'Footer 字号可以再小 2px' },
];
const wins = [
'Hero 字号 96 → 72',
'CTA 改成 terra 主色',
'Footer 字号 14 → 12',
];
const col1T = interpolate(elapsed, [0.4, 1.2], [0, 1], Easing.easeOut);
const col2T = interpolate(elapsed, [0.8, 1.6], [0, 1], Easing.easeOut);
const col3T = interpolate(elapsed, [1.2, 2.0], [0, 1], Easing.easeOut);
const footerOp = interpolate(elapsed, [3.8, 4.6], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
padding: '70px 90px 60px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: headerOp, marginBottom: 40,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 6}}>步骤 3 / 3 · 行动清单</div>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
letterSpacing:'-0.01em'}}>
Keep · Fix · <span style={{fontStyle:'italic', color: TERRA}}>Quick Wins</span>
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
textAlign:'right', lineHeight: 1.5}}>
打完分 · 不是扔下报告,<br/>
是给一张可执行的「修复清单」
</div>
</div>
<div style={{flex: 1, display:'grid', gridTemplateColumns:'1fr 1.15fr 1fr',
gap: 28}}>
{/* KEEP */}
<div style={{opacity: col1T, transform:`translateY(${(1-col1T)*20}px)`,
background:'#fff', border:`1px solid ${LINE}`,
borderTop: `4px solid ${OLIVE}`, padding: '30px 30px 28px',
display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 24}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: OLIVE,
letterSpacing:'0.3em', marginBottom: 8}}>KEEP</div>
<div style={{fontFamily: serif, fontSize: 32, fontWeight: 500, color: INK}}>
保持这些
</div>
</div>
<div style={{fontFamily: serif, fontSize: 44, fontStyle:'italic',
fontWeight: 400, color: OLIVE, lineHeight: 1}}>
3
</div>
</div>
<div style={{display:'flex', flexDirection:'column', gap: 18, flex: 1}}>
{keeps.map((k, i) => (
<div key={i} style={{display:'flex', gap: 14, alignItems:'flex-start'}}>
<div style={{fontFamily: mono, fontSize: 16, color: OLIVE,
fontWeight: 600, marginTop: 2}}>✓</div>
<div style={{fontFamily: serif, fontSize: 19, color: INK,
lineHeight: 1.5, flex: 1}}>{k}</div>
</div>
))}
</div>
</div>
{/* FIX */}
<div style={{opacity: col2T, transform:`translateY(${(1-col2T)*20}px)`,
background:'#fff', border:`1px solid ${LINE}`,
borderTop: `4px solid ${TERRA}`, padding: '30px 30px 28px',
display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 24}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 8}}>FIX</div>
<div style={{fontFamily: serif, fontSize: 32, fontWeight: 500, color: INK}}>
需修复 · 按严重度
</div>
</div>
<div style={{fontFamily: serif, fontSize: 44, fontStyle:'italic',
fontWeight: 400, color: TERRA, lineHeight: 1}}>
3
</div>
</div>
<div style={{display:'flex', flexDirection:'column', gap: 16, flex: 1}}>
{fixes.map((f, i) => (
<div key={i} style={{display:'flex', gap: 14, alignItems:'flex-start',
paddingBottom: 14, borderBottom: i < fixes.length - 1 ? `1px solid ${LINE}` : 'none'}}>
<div style={{
background: f.sev, color: '#fff',
fontFamily: mono, fontSize: 10, letterSpacing:'0.15em',
padding: '4px 10px', marginTop: 4, minWidth: 58,
textAlign: 'center', fontWeight: 600,
}}>
{f.tag}
</div>
<div style={{fontFamily: serif, fontSize: 17, color: INK,
lineHeight: 1.5, flex: 1}}>{f.text}</div>
</div>
))}
</div>
</div>
{/* QUICK WINS */}
<div style={{opacity: col3T, transform:`translateY(${(1-col3T)*20}px)`,
background:'#fff', border:`1px solid ${LINE}`,
borderTop: `4px solid ${DEEP_BLUE}`, padding: '30px 30px 28px',
display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 24}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: DEEP_BLUE,
letterSpacing:'0.3em', marginBottom: 8}}>QUICK WINS</div>
<div style={{fontFamily: serif, fontSize: 32, fontWeight: 500, color: INK}}>
5 分钟能做的
</div>
</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.2em', textAlign:'right', lineHeight: 1.6}}>
TOP<br/>3
</div>
</div>
<div style={{display:'flex', flexDirection:'column', gap: 18, flex: 1}}>
{wins.map((w, i) => (
<div key={i} style={{display:'flex', gap: 16, alignItems:'flex-start'}}>
<div style={{fontFamily: serif, fontSize: 32, fontStyle:'italic',
color: DEEP_BLUE, fontWeight: 400, lineHeight: 1, minWidth: 32,
marginTop: -4}}>
{i+1}
</div>
<div style={{fontFamily: serif, fontSize: 19, color: INK,
lineHeight: 1.5, flex: 1}}>{w}</div>
</div>
))}
</div>
</div>
</div>
{/* Footer slogan */}
<div style={{marginTop: 36, textAlign:'center', opacity: footerOp}}>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 26,
color: TERRA, letterSpacing:'0.02em'}}>
不是给个评价 · 是给个修复清单
</div>
</div>
</div>
);
}
// ── Scene 5: Outro (20 – 22s) ─────────────────────────
function Scene5_Outro() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
const titleY = interpolate(elapsed, [0, 1.0], [40, 0], Easing.easeOut);
const lineW = interpolate(elapsed, [0.5, 1.3], [0, 540]);
const subOp = interpolate(elapsed, [0.9, 1.6], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 22}}>
5 维度 · 客观 · 可操作
</div>
<div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
color: INK, lineHeight: 1, letterSpacing:'-0.015em',
transform: `translateY(${titleY}px)`}}>
先打分 · 再<span style={{fontStyle:'italic', color: TERRA}}>修</span>
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
marginTop: 26, opacity: subOp, letterSpacing:'0.02em'}}>
Huashu-Design · Expert Review
</div>
</div>
);
}
// ── Watermark ──────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
function App() {
return (
<Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
<Sprite start={3} end={8}><Scene2_Dimensions /></Sprite>
<Sprite start={8} end={14}><Scene3_Radar /></Sprite>
<Sprite start={14} end={20}><Scene4_Actions /></Sprite>
<Sprite start={20} end={22}><Scene5_Outro /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+787
View File
@@ -0,0 +1,787 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · 品牌资产协议 5 步硬流程</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: #0c0c0c;
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [inStart, inEnd] = input;
const [outStart, outEnd] = output;
if (t <= inStart) return outStart;
if (t >= inEnd) return outEnd;
let progress = (t - inStart) / (inEnd - inStart);
if (easing) progress = easing(progress);
return outStart + (outEnd - outStart) * progress;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() {
const sprite = useContext(SpriteContext);
return sprite || { t: 0, elapsed: 0, duration: 0 };
}
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
function updateScale() {
const vw = window.innerWidth;
const vh = window.innerHeight - 56;
const s = Math.min(vw / width, vh / height);
setScale(s);
}
updateScale();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false;
let last = null;
function tick(now) {
if (cancelled) return;
if (last === null) {
last = now;
if (typeof window !== 'undefined') window.__ready = true;
}
const delta = (now - last) / 1000;
last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
const canvasStyle = {
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
width,
height,
background: bgColor,
overflow: 'hidden',
transform: `translate(-50%, -50%) scale(${scale})`,
};
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={canvasStyle}>{children}</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
const spriteValue = { t, elapsed, duration, start, end: actualEnd };
return (
<SpriteContext.Provider value={spriteValue}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const OLIVE = '#6a6b4e';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── Scene 1: Trigger (0 – 3s) ─────────────────────────────
function Scene1_Trigger() {
const { elapsed } = useSprite();
const labelOp = interpolate(elapsed, [0, 0.5], [0, 1]);
const titleY = interpolate(elapsed, [0, 1], [30, 0], Easing.easeOut);
const titleOp = interpolate(elapsed, [0.2, 1], [0, 1]);
const brandsOp = interpolate(elapsed, [1, 1.6], [0, 1]);
const switchOp = interpolate(elapsed, [1.9, 2.4], [0, 1]);
const fadeOut = interpolate(elapsed, [2.6, 3], [1, 0]);
const brands = ['Kimi', 'Linear', 'Lovart', 'Stripe'];
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 28, opacity: labelOp}}>
品 牌 资 产 协 议 · BRAND PROTOCOL
</div>
<div style={{fontFamily: serif, fontSize: 120, fontWeight: 500, color: INK,
lineHeight: 1, letterSpacing:'-0.01em',
opacity: titleOp, transform: `translateY(${titleY}px)`}}>
涉及具体品牌<span style={{color: TERRA, fontStyle:'italic'}}>?</span>
</div>
<div style={{display:'flex', gap: 48, marginTop: 64, opacity: brandsOp}}>
{brands.map((b, i) => (
<span key={i} style={{fontFamily: serif, fontStyle:'italic',
fontSize: 44, color: ASH, letterSpacing:'0.02em'}}>
{b}
</span>
))}
</div>
<div style={{marginTop: 72, opacity: switchOp, textAlign:'center'}}>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 34,
color: TERRA, letterSpacing:'0.02em'}}>
先停下——走 5 步硬流程
</div>
<div style={{height:1, background: TERRA, width: 180, margin:'18px auto 0'}} />
</div>
</div>
);
}
// ── Scene 2: Step 1 & 2 (3 – 7s) ──────────────────────────
function Scene2_AskAndSearch() {
const { elapsed } = useSprite();
const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const leftOp = interpolate(elapsed, [0.3, 0.9], [0, 1]);
const leftY = interpolate(elapsed, [0.3, 0.9], [20, 0], Easing.easeOut);
const rightOp = interpolate(elapsed, [0.8, 1.4], [0, 1]);
const rightY = interpolate(elapsed, [0.8, 1.4], [20, 0], Easing.easeOut);
const fadeOut = interpolate(elapsed, [3.5, 4], [1, 0]);
// cursor sweeping through search paths 1.6 → 3.2
const paths = [
'<brand>.com/brand',
'<brand>.com/press',
'brand.<brand>.com',
'<brand>.github.io/brand',
];
// current active index based on time
const activeIdx = Math.min(3, Math.max(0, Math.floor((elapsed - 1.6) / 0.4)));
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
padding:'72px 120px', display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', opacity: headOp, marginBottom: 48}}>
<div style={{fontFamily: serif, fontSize: 44, fontWeight: 500, color: INK}}>
Step 1 & 2 · 问 · 搜
</div>
<div style={{fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.25em'}}>
01 / 05 → 02 / 05
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 48, flex:1}}>
{/* Left: Step 1 Ask */}
<div style={{opacity: leftOp, transform:`translateY(${leftY}px)`,
display:'flex', flexDirection:'column', gap: 24}}>
<div style={{display:'flex', alignItems:'baseline', gap: 18}}>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em'}}>STEP · 01</div>
<div style={{fontFamily: serif, fontSize: 40, color: INK,
fontWeight: 500}}>问</div>
</div>
<div style={{background:'#fff', border:`1px solid ${LINE}`,
padding:'32px 34px', position:'relative'}}>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 64,
color: TERRA, lineHeight:1, position:'absolute',
top: 8, left: 14}}>「</div>
<div style={{fontFamily: serif, fontSize: 24, color: INK,
lineHeight: 1.55, paddingLeft: 36}}>
这个品牌有 <span style={{fontStyle:'italic', color: TERRA}}>brand guidelines</span> 吗?
</div>
<div style={{height:1, background: LINE, margin:'22px 0'}} />
<div style={{fontFamily: serif, fontSize: 20, color: ASH,
lineHeight: 1.6, paddingLeft: 36}}>
有的话直接给我;没有我去搜。
</div>
</div>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.15em', marginTop: 6}}>
▸ 用户提供 &gt; AI 猜测
</div>
</div>
{/* Right: Step 2 Search */}
<div style={{opacity: rightOp, transform:`translateY(${rightY}px)`,
display:'flex', flexDirection:'column', gap: 24}}>
<div style={{display:'flex', alignItems:'baseline', gap: 18}}>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em'}}>STEP · 02</div>
<div style={{fontFamily: serif, fontSize: 40, color: INK,
fontWeight: 500}}>搜 官 方 品 牌 页</div>
</div>
<div style={{background:'#1a1a1a', color:'#e8e3d6',
padding:'24px 28px', fontFamily: mono, fontSize: 16,
lineHeight: 1.9, flex: 1}}>
<div style={{color:'#6b6b6b', fontSize: 11,
letterSpacing:'0.2em', marginBottom: 14}}>
$ HTTP GET · typical paths
</div>
{paths.map((p, i) => {
const isActive = i === activeIdx && elapsed > 1.6 && elapsed < 3.4;
const visited = i < activeIdx;
return (
<div key={i} style={{
color: isActive ? TERRA : (visited ? '#8a8878' : '#e8e3d6'),
background: isActive ? 'rgba(192,74,26,0.12)' : 'transparent',
padding:'2px 8px', marginLeft:-8,
display:'flex', alignItems:'baseline', gap: 14}}>
<span style={{color: isActive ? TERRA : '#555', fontSize: 13}}>
{isActive ? '▸' : (visited ? '✓' : ' ')}
</span>
<span style={{letterSpacing:'0.02em'}}>{p}</span>
</div>
);
})}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, marginTop: 6}}>
按顺序试,中断也要记 404 状态
</div>
</div>
</div>
</div>
);
}
// ── Scene 3: Step 3 · Three fallbacks (7 – 12s) ───────────
function Scene3_Fallbacks() {
const { elapsed } = useSprite();
const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const cardsOp = interpolate(elapsed, [0.3, 1], [0, 1]);
const fadeOut = interpolate(elapsed, [4.5, 5], [1, 0]);
// Card state: 0 idle, 1 active/lit, 2 failed/gray, 3 success
// Card 1 active 1.2→2.0, then fails at 2.0
// Card 2 active 2.2→3.0, then fails at 3.0
// Card 3 active 3.2→4.2, succeeds at 4.2
const card1State = elapsed < 1.2 ? 0 : elapsed < 2.0 ? 1 : 2;
const card2State = elapsed < 2.2 ? 0 : elapsed < 3.0 ? 1 : (elapsed < 3.2 ? 2 : 2);
const card3State = elapsed < 3.2 ? 0 : elapsed < 4.2 ? 1 : 3;
const captionOp = interpolate(elapsed, [4.2, 4.6], [0, 1]);
const cards = [
{ n: '01', title: 'SVG 文件', cmd: 'curl -o logo.svg <url>', status: '最理想', state: card1State },
{ n: '02', title: '官网 HTML', cmd: 'curl -A "Mozilla/5.0" \\\n -L <url>', status: '80% 场景必用', state: card2State },
{ n: '03', title: '产品截图取色', cmd: 'macOS Preview · 吸管', status: 'App 必走', state: card3State },
];
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
padding:'70px 100px 60px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: headOp, marginBottom: 40}}>
<div style={{display:'flex', alignItems:'baseline', gap: 18, marginBottom: 6}}>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em'}}>STEP · 03</div>
<div style={{fontFamily: serif, fontSize: 18, color: ASH,
fontStyle:'italic'}}>下 · download</div>
</div>
<div style={{fontFamily: serif, fontSize: 60, fontWeight: 500,
color: INK, letterSpacing:'-0.01em'}}>
三条兜底路径
</div>
<div style={{height:1, background: LINE, width:'100%', marginTop: 20}} />
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr',
gap: 28, flex:1, opacity: cardsOp}}>
{cards.map((c, i) => {
const isIdle = c.state === 0;
const isActive = c.state === 1;
const isFailed = c.state === 2;
const isSuccess = c.state === 3;
const borderColor = isSuccess ? TERRA :
isActive ? INK :
isFailed ? '#c4bda7' : LINE;
const borderWidth = (isActive || isSuccess) ? 2 : 1;
const op = isFailed ? 0.42 : 1;
const bg = isSuccess ? '#fffaf3' : '#fff';
return (
<div key={i} style={{
background: bg,
border:`${borderWidth}px solid ${borderColor}`,
opacity: op,
padding:'28px 28px 24px',
display:'flex', flexDirection:'column',
position:'relative',
transition: 'none',
}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 20}}>
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 300,
color: isSuccess ? TERRA : INK, lineHeight:1}}>
{c.n}
</div>
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.2em',
color: isSuccess ? TERRA : isFailed ? ASH : INK}}>
{isIdle && 'READY'}
{isActive && '▸ TRYING…'}
{isFailed && '× FAILED'}
{isSuccess && '✓ GOT IT'}
</div>
</div>
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500,
color: INK, marginBottom: 14, letterSpacing:'-0.005em'}}>
{c.title}
</div>
<div style={{background:'#1a1a1a', color:'#e8e3d6',
fontFamily: mono, fontSize: 12, padding:'14px 16px',
whiteSpace:'pre-wrap', lineHeight: 1.6,
marginBottom: 20, borderLeft: `2px solid ${isSuccess ? TERRA : '#333'}`}}>
{c.cmd}
</div>
<div style={{marginTop:'auto', borderTop:`1px solid ${LINE}`,
paddingTop: 14, display:'flex', justifyContent:'space-between',
alignItems:'baseline'}}>
<div style={{fontFamily: serif, fontStyle:'italic',
fontSize: 15, color: ASH}}>
场景
</div>
<div style={{fontFamily: serif, fontSize: 16,
color: isSuccess ? TERRA : INK, fontWeight: 500}}>
{c.status}
</div>
</div>
{isSuccess && (
<div style={{position:'absolute', top:-10, right:-10,
width: 28, height: 28, borderRadius:'50%', background: TERRA,
color:'#fff', display:'flex', alignItems:'center',
justifyContent:'center', fontFamily: serif, fontSize: 14,
fontWeight: 600}}>
✓
</div>
)}
</div>
);
})}
</div>
<div style={{opacity: captionOp, marginTop: 22, textAlign:'center',
fontFamily: serif, fontStyle:'italic', fontSize: 22, color: INK}}>
前一条失败,<span style={{color: TERRA}}>立刻</span>走下一条——不要停
</div>
</div>
);
}
// ── Scene 4: Step 4 · Grep colors (12 – 17s) ──────────────
function Scene4_GrepColors() {
const { elapsed } = useSprite();
const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const cmdOp = interpolate(elapsed, [0.4, 0.9], [0, 1]);
const fadeOut = interpolate(elapsed, [4.5, 5], [1, 0]);
const results = [
{ count: 47, hex: '#1783FF', label: 'Kimi primary' },
{ count: 32, hex: '#FAFAFA', label: 'background' },
{ count: 18, hex: '#1a1a1a', label: 'ink' },
{ count: 12, hex: '#FF6B35', label: 'accent' },
{ count: 8, hex: '#6A6B4E', label: 'muted' },
];
// Stagger each result row, starting at t=1.5
const rowStart = 1.5;
const rowStep = 0.32;
const captionOp = interpolate(elapsed, [3.6, 4.1], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background:'#1a1a1a', opacity: fadeOut,
padding:'64px 100px', display:'flex', flexDirection:'column',
color:'#e8e3d6'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 36, opacity: headOp}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 4}}>STEP · 04</div>
<div style={{fontFamily: serif, fontSize: 54, fontWeight: 500,
color:'#faf6ef', letterSpacing:'-0.01em'}}>
Grep 色 值
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color:'#8a8878', textAlign:'right'}}>
频次排序 = 品牌色权重<br/>
<span style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.2em'}}>
/ DON'T GUESS · COUNT
</span>
</div>
</div>
{/* Command */}
<div style={{opacity: cmdOp, background:'#0e0e0e',
border:'1px solid #333', padding:'20px 26px',
fontFamily: mono, fontSize: 15, lineHeight: 1.7,
marginBottom: 28}}>
<div style={{color: TERRA, fontSize: 10, letterSpacing:'0.2em',
marginBottom: 10}}>$ COMMAND</div>
<div style={{color:'#e8e3d6'}}>
<span style={{color:'#888'}}>$</span>{' '}
<span style={{color:'#7dd3fc'}}>grep</span>{' '}
<span style={{color:'#fbbf24'}}>-hoE</span>{' '}
<span style={{color:'#a5f3c5'}}>'#[0-9A-Fa-f]{'{6}'}'</span>{' '}
<span style={{color:'#e8e3d6'}}>assets/*.{'{svg,html}'}</span> \<br/>
{' '}<span style={{color:'#666'}}>|</span>{' '}
<span style={{color:'#7dd3fc'}}>sort</span>{' '}
<span style={{color:'#666'}}>|</span>{' '}
<span style={{color:'#7dd3fc'}}>uniq -c</span>{' '}
<span style={{color:'#666'}}>|</span>{' '}
<span style={{color:'#7dd3fc'}}>sort -rn</span>{' '}
<span style={{color:'#666'}}>|</span>{' '}
<span style={{color:'#7dd3fc'}}>head -20</span>
</div>
</div>
{/* Results + swatches */}
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 60,
flex: 1}}>
{/* Left: result list */}
<div style={{fontFamily: mono, fontSize: 18, lineHeight: 2}}>
<div style={{color:'#555', fontSize: 10, letterSpacing:'0.2em',
marginBottom: 12}}>▸ RESULT · top 5</div>
{results.map((r, i) => {
const visible = elapsed > (rowStart + i * rowStep);
if (!visible) return <div key={i} style={{height: 36}}/>;
const fadeIn = interpolate(elapsed,
[rowStart + i*rowStep, rowStart + i*rowStep + 0.3],
[0, 1]);
return (
<div key={i} style={{opacity: fadeIn, display:'flex',
alignItems:'center', gap: 20}}>
<span style={{color:'#fbbf24', minWidth: 40, textAlign:'right'}}>
{String(r.count).padStart(3, ' ').replace(/ /g, '\u00A0')}
</span>
<span style={{color:'#e8e3d6'}}>{r.hex}</span>
<span style={{color:'#666', fontSize: 14}}>←</span>
<span style={{color:'#8a8878', fontStyle:'italic',
fontFamily: serif, fontSize: 16}}>{r.label}</span>
</div>
);
})}
</div>
{/* Right: color swatches */}
<div style={{display:'flex', flexDirection:'column', gap: 14}}>
<div style={{color:'#555', fontFamily: mono, fontSize: 10,
letterSpacing:'0.2em', marginBottom: 2}}>▸ PALETTE</div>
{results.map((r, i) => {
const visible = elapsed > (rowStart + i * rowStep);
if (!visible) return <div key={i} style={{height: 54}}/>;
const fadeIn = interpolate(elapsed,
[rowStart + i*rowStep, rowStart + i*rowStep + 0.3],
[0, 1]);
const w = interpolate(elapsed,
[rowStart + i*rowStep, rowStart + i*rowStep + 0.5],
[0, r.count * 9]);
return (
<div key={i} style={{opacity: fadeIn, display:'flex',
alignItems:'center', gap: 14, height: 50}}>
<div style={{width: 50, height: 50, background: r.hex,
border:'1px solid #333', flexShrink: 0}} />
<div style={{height: 24, background: r.hex,
width: `${w}px`, opacity: 0.7}} />
<div style={{fontFamily: mono, fontSize: 12,
color:'#8a8878'}}>×{r.count}</div>
</div>
);
})}
</div>
</div>
{/* Key caption */}
<div style={{opacity: captionOp, textAlign:'center', marginTop: 18,
paddingTop: 18, borderTop:'1px solid #333'}}>
<span style={{fontFamily: serif, fontStyle:'italic', fontSize: 26,
color: TERRA}}>
不要凭记忆猜品牌色
</span>
<span style={{fontFamily: serif, fontSize: 20, color:'#8a8878',
marginLeft: 14}}>
——频次说了算
</span>
</div>
</div>
);
}
// ── Scene 5: Step 5 · brand-spec.md (17 – 22s) ────────────
function Scene5_SpecFile() {
const { elapsed } = useSprite();
const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
const mdOp = interpolate(elapsed, [0.3, 0.9], [0, 1]);
const mdX = interpolate(elapsed, [0.3, 0.9], [-40, 0], Easing.easeOut);
const cssOp = interpolate(elapsed, [0.9, 1.5], [0, 1]);
const cssX = interpolate(elapsed, [0.9, 1.5], [40, 0], Easing.easeOut);
const arrowOp = interpolate(elapsed, [2.2, 2.7], [0, 1]);
const captionOp = interpolate(elapsed, [3.4, 3.9], [0, 1]);
const fadeOut = interpolate(elapsed, [4.5, 5], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
padding:'64px 80px 48px', display:'flex', flexDirection:'column'}}>
<div style={{opacity: headOp, marginBottom: 28, display:'flex',
justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing:'0.3em', marginBottom: 4}}>STEP · 05 · FINAL</div>
<div style={{fontFamily: serif, fontSize: 54, fontWeight: 500,
color: INK, letterSpacing:'-0.01em'}}>
固化为 <span style={{fontStyle:'italic'}}>brand-spec.md</span>
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign:'right'}}>
单一真相源 · single source of truth
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'1.1fr 0.9fr',
gap: 40, flex:1, position:'relative'}}>
{/* Left: .md preview */}
<div style={{opacity: mdOp, transform:`translateX(${mdX}px)`,
background:'#fff', border:`1px solid ${LINE}`,
display:'flex', flexDirection:'column'}}>
<div style={{padding:'12px 20px', borderBottom:`1px solid ${LINE}`,
display:'flex', justifyContent:'space-between', alignItems:'center',
background:'#f7f2e8'}}>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing:'0.15em'}}>
▸ brand-spec.md
</div>
<div style={{fontFamily: mono, fontSize: 10, color: TERRA,
letterSpacing:'0.15em'}}>
✓ COMMITTED
</div>
</div>
<div style={{padding:'22px 28px', fontFamily: mono, fontSize: 12,
lineHeight: 1.75, color: INK, flex:1, overflow:'hidden'}}>
<div style={{color: ASH}}>---</div>
<div><span style={{color: OLIVE}}>brand</span>: Kimi</div>
<div><span style={{color: OLIVE}}>fetched</span>: 2026-04-20</div>
<div style={{color: ASH}}>---</div>
<div style={{height: 10}} />
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
marginBottom: 4}}>## 色板</div>
<div>- primary: <span style={{color: TERRA}}>#1783FF</span> (47)</div>
<div>- bg: <span style={{color: TERRA}}>#FAFAFA</span> (32)</div>
<div>- ink: <span style={{color: TERRA}}>#1a1a1a</span> (18)</div>
<div>- accent: <span style={{color: TERRA}}>#FF6B35</span> (12)</div>
<div style={{height: 12}} />
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
marginBottom: 4}}>## 字型</div>
<div>- display: <span style={{color: OLIVE}}>Newsreader</span></div>
<div>- body: <span style={{color: OLIVE}}>Inter</span></div>
<div>- mono: <span style={{color: OLIVE}}>JetBrains Mono</span></div>
<div style={{height: 12}} />
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
marginBottom: 4}}>## 签名细节</div>
<div>- 1px hairlines</div>
<div>- 圆角半径统一 4px</div>
<div style={{height: 12}} />
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
marginBottom: 4, color: TERRA}}>## 禁区</div>
<div style={{color: ASH}}>- 不用紫渐变 / emoji / 4px 以上 shadow</div>
</div>
</div>
{/* Right: CSS vars + consumers */}
<div style={{opacity: cssOp, transform:`translateX(${cssX}px)`,
display:'flex', flexDirection:'column', gap: 20}}>
<div style={{background:'#0e0e0e', color:'#e8e3d6',
fontFamily: mono, fontSize: 14, padding:'22px 26px',
lineHeight: 1.7, flex: 1}}>
<div style={{color:'#666', fontSize: 10, letterSpacing:'0.2em',
marginBottom: 12}}>/* tokens.css */</div>
<div><span style={{color:'#7dd3fc'}}>:root</span> {'{'}</div>
<div> <span style={{color:'#a5f3c5'}}>--brand-primary</span>: <span style={{color:'#fbbf24'}}>#1783FF</span>;</div>
<div> <span style={{color:'#a5f3c5'}}>--brand-bg</span>: <span style={{color:'#fbbf24'}}>#FAFAFA</span>;</div>
<div> <span style={{color:'#a5f3c5'}}>--brand-ink</span>: <span style={{color:'#fbbf24'}}>#1a1a1a</span>;</div>
<div> <span style={{color:'#a5f3c5'}}>--brand-accent</span>: <span style={{color:'#fbbf24'}}>#FF6B35</span>;</div>
<div>{'}'}</div>
</div>
{/* Arrow cascade */}
<div style={{opacity: arrowOp, position:'relative',
background:'#fff', border:`1px solid ${LINE}`,
padding:'20px 24px'}}>
<div style={{fontFamily: mono, fontSize: 10, color: TERRA,
letterSpacing:'0.2em', marginBottom: 12}}>▸ CONSUMERS</div>
<div style={{display:'flex', alignItems:'center', gap: 16,
fontFamily: mono, fontSize: 13, color: INK, flexWrap:'wrap'}}>
<span style={{padding:'4px 10px', border:`1px solid ${TERRA}`,
color: TERRA}}>brand-spec.md</span>
<span style={{color: TERRA}}>→</span>
<span style={{padding:'4px 10px', border:`1px solid ${INK}`}}>:root</span>
<span style={{color: TERRA}}>→</span>
<span style={{padding:'4px 10px', background: INK, color:'#fff'}}>all .html</span>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
color: ASH, marginTop: 14, lineHeight: 1.5}}>
一个文件改动,所有 HTML 自动跟随。
</div>
</div>
</div>
</div>
<div style={{opacity: captionOp, marginTop: 22, textAlign:'center',
fontFamily: serif, fontStyle:'italic', fontSize: 22, color: INK}}>
全协议的<span style={{color: TERRA}}>制胜点</span>——让所有 CSS 引用这份文件
</div>
</div>
);
}
// ── Scene 6: Final (22 – 24s) ─────────────────────────────
function Scene6_Final() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.5], [0, 1], Easing.easeOut);
const titleY = interpolate(elapsed, [0, 0.8], [24, 0], Easing.easeOut);
const lineW = interpolate(elapsed, [0.5, 1.3], [0, 580]);
const subOp = interpolate(elapsed, [0.8, 1.4], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeIn,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 24}}>
01 → 02 → 03 → 04 → <span style={{color: INK}}>05 · DONE</span>
</div>
<div style={{fontFamily: serif, fontSize: 92, fontWeight: 500,
color: INK, lineHeight: 1.1, letterSpacing:'-0.01em',
textAlign:'center', transform: `translateY(${titleY}px)`}}>
<span style={{fontStyle:'italic', color: TERRA}}>15 分钟</span>的投资<br/>
省下 <span style={{fontStyle:'italic'}}>1–2 小时</span> 返工
</div>
<div style={{height:1, background: INK, width: lineW, marginTop: 40}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
color: ASH, marginTop: 28, opacity: subOp,
letterSpacing:'0.02em'}}>
这是稳定性最便宜的投资。
</div>
</div>
);
}
// ── Watermark ─────────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
// ── Main composition ──────────────────────────────────────
function App() {
return (
<Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Trigger /></Sprite>
<Sprite start={3} end={7}><Scene2_AskAndSearch /></Sprite>
<Sprite start={7} end={12}><Scene3_Fallbacks /></Sprite>
<Sprite start={12} end={17}><Scene4_GrepColors /></Sprite>
<Sprite start={17} end={22}><Scene5_SpecFile /></Sprite>
<Sprite start={22} end={24}><Scene6_Final /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+856
View File
@@ -0,0 +1,856 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Junior Designer 模式</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: #0c0c0c;
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [inStart, inEnd] = input;
const [outStart, outEnd] = output;
if (t <= inStart) return outStart;
if (t >= inEnd) return outEnd;
let progress = (t - inStart) / (inEnd - inStart);
if (easing) progress = easing(progress);
return outStart + (outEnd - outStart) * progress;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() {
const sprite = useContext(SpriteContext);
return sprite || { t: 0, elapsed: 0, duration: 0 };
}
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
function updateScale() {
const vw = window.innerWidth;
const vh = window.innerHeight - 56;
const s = Math.min(vw / width, vh / height);
setScale(s);
}
updateScale();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false;
let last = null;
function tick(now) {
if (cancelled) return;
if (last === null) {
last = now;
if (typeof window !== 'undefined') window.__ready = true;
}
const delta = (now - last) / 1000;
last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
const canvasStyle = {
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
width,
height,
background: bgColor,
overflow: 'hidden',
transform: `translate(-50%, -50%) scale(${scale})`,
};
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={canvasStyle}>{children}</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
const spriteValue = { t, elapsed, duration, start, end: actualEnd };
return (
<SpriteContext.Provider value={spriteValue}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
const OLIVE = '#6a6b4e';
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── Scene 1: Two working modes contrast (0 – 3s) ──────────
function Scene1_Contrast() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.6], [0, 1]);
const leftOp = interpolate(elapsed, [0.3, 1], [0, 1]);
const rightOp = interpolate(elapsed, [0.5, 1.2], [0, 1]);
// Left: long bar filling slowly, then user frown at end
const badBar = Math.min(1, Math.max(0, (elapsed - 1.0) / 1.6));
const badReveal = elapsed > 2.5 ? 1 : 0;
// Right: small milestones ticking one by one
const ticks = [0.9, 1.3, 1.7, 2.1, 2.5];
const fadeOut = interpolate(elapsed, [2.85, 3], [1, 0], Easing.easeIn);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
padding:'80px 120px', display:'flex', flexDirection:'column'}}>
{/* Title */}
<div style={{textAlign:'center', marginBottom: 60, opacity: titleOp}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 16}}>
JUNIOR DESIGNER MODE · 工作方式对比
</div>
<div style={{fontFamily: serif, fontSize: 68, fontWeight: 500,
color: INK, lineHeight: 1.1, letterSpacing:'-0.01em'}}>
理解错了 <span style={{fontStyle:'italic', color: TERRA}}>早改</span> 比 <span style={{fontStyle:'italic', color: TERRA}}>晚改</span> 便宜 100 倍
</div>
</div>
{/* Two columns */}
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 60, flex: 1}}>
{/* Left: bad mode */}
<div style={{opacity: leftOp, background:'#f4eee3', padding:'40px 44px',
display:'flex', flexDirection:'column', position:'relative'}}>
<div style={{display:'flex', alignItems:'center', gap: 14, marginBottom: 28}}>
<div style={{width: 32, height: 32, borderRadius:'50%',
background:'#ccc', color:'#fff', fontFamily: serif, fontSize: 20,
fontWeight: 600, display:'flex', alignItems:'center',
justifyContent:'center', lineHeight: 1}}>✗</div>
<div style={{fontFamily: serif, fontSize: 30, fontWeight: 500,
color:'#555'}}>闷头做大招</div>
</div>
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
letterSpacing: '0.2em', marginBottom: 14}}>
6 HOURS · 0 FEEDBACK
</div>
<div style={{height: 18, background:'#e2dbcd', position:'relative',
marginBottom: 36}}>
<div style={{position:'absolute', top:0, left:0, height:'100%',
width: `${badBar * 100}%`, background:'#999'}} />
</div>
<div style={{fontFamily: serif, fontSize: 16, color: ASH,
lineHeight: 1.7, flex: 1}}>
埋头 6 小时<br/>
一次性端出成品<br/>
默祷「方向没理解错」
</div>
{badReveal > 0 && (
<div style={{marginTop: 20, padding:'14px 18px', background:'#fff',
border: `1px solid #d0c8b8`, opacity: badReveal,
display:'flex', alignItems:'center', gap: 12}}>
<div style={{fontFamily: serif, fontSize: 22, color:'#888'}}>😐</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
color:'#666', lineHeight: 1.4}}>
「这方向不对…<br/>得重来」
</div>
</div>
)}
</div>
{/* Right: good mode */}
<div style={{opacity: rightOp, background:'#fff', padding:'40px 44px',
border: `1.5px solid ${TERRA}`, display:'flex', flexDirection:'column',
position:'relative'}}>
<div style={{display:'flex', alignItems:'center', gap: 14, marginBottom: 28}}>
<div style={{width: 32, height: 32, borderRadius:'50%',
background: TERRA, color:'#fff', fontFamily: serif, fontSize: 20,
fontWeight: 600, display:'flex', alignItems:'center',
justifyContent:'center', lineHeight: 1}}>✓</div>
<div style={{fontFamily: serif, fontSize: 30, fontWeight: 500,
color: INK}}>Junior Designer 模式</div>
</div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing: '0.2em', marginBottom: 14}}>
5 CHECKPOINTS · CONTINUOUS FEEDBACK
</div>
{/* Timeline with ticks */}
<div style={{position:'relative', height: 18, marginBottom: 36}}>
<div style={{position:'absolute', top: 8, left: 0, right: 0,
height: 2, background:'#e8e1d3'}} />
{ticks.map((tick, i) => {
const show = elapsed > tick ? 1 : 0;
const pct = (i + 1) / ticks.length * 100;
return (
<div key={i} style={{position:'absolute',
left: `calc(${pct}% - 10px)`, top: 0, width: 20, height: 20,
borderRadius:'50%', background: TERRA, color:'#fff',
fontSize: 11, display:'flex', alignItems:'center',
justifyContent:'center', opacity: show,
transform: `scale(${show})`, transition:'none',
fontFamily: sans, fontWeight: 600}}>✓</div>
);
})}
</div>
<div style={{fontFamily: serif, fontSize: 16, color: INK,
lineHeight: 1.7, flex: 1}}>
假设 · 骨架 · 粗图<br/>
每步都 show 一下<br/>
错得早,改得小
</div>
{elapsed > 2.5 && (
<div style={{marginTop: 20, padding:'14px 18px', background:'#faf6ef',
border: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 12,
opacity: interpolate(elapsed, [2.5, 2.85], [0, 1])}}>
<div style={{fontFamily: serif, fontSize: 22}}>🙂</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
color: INK, lineHeight: 1.4}}>
「方向对,<br/>继续推进」
</div>
</div>
)}
</div>
</div>
</div>
);
}
// ── Scene 2: HTML skeleton with assumptions + placeholders (3 – 8s) ──
function Scene2_Skeleton() {
const { elapsed } = useSprite();
const opacity = interpolate(elapsed, [0, 0.5], [0, 1]);
const fadeOut = interpolate(elapsed, [4.6, 5], [1, 0]);
// Typewriter content
const fullText = `<!--
ASSUMPTIONS:
- 主色用暖调橙(未确认,等品牌 spec)
- 3 屏切换,Today / Memory / Chat
- 字体:Newsreader + Noto Serif SC
PLACEHOLDERS:
- Hero 图暂用灰块 + 文字标签(有图再替换)
- AI 洞察文本用「等用户提供真实数据」
REASONING:
- 选橙调是因为客户说要「温暖感」
- Serif 因为是阅读类 App
-->`;
// Type at ~50 chars/sec
const charCount = Math.floor(interpolate(elapsed, [0.4, 4.0], [0, fullText.length]));
const shownText = fullText.slice(0, charCount);
const cursorBlink = Math.floor(elapsed * 2.4) % 2 === 0 && charCount < fullText.length;
// Hint callout appears after most text typed
const hintOp = interpolate(elapsed, [3.5, 4.2], [0, 1]);
const hintBob = Math.sin(elapsed * 3) * 4;
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: opacity * fadeOut,
padding:'60px 100px', display:'flex', flexDirection:'column'}}>
{/* Header */}
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 32}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing: '0.3em', marginBottom: 6}}>STEP 1 / 3</div>
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
先写 <span style={{fontStyle:'italic', color: TERRA}}>骨架</span> · 不写渲染
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign: 'right', lineHeight: 1.5}}>
Assumptions + Placeholders + Reasoning<br/>
<span style={{fontSize: 14}}>— 把「猜」写在代码里</span>
</div>
</div>
{/* Editor */}
<div style={{flex: 1, background:'#1a1a1a', position:'relative',
display:'flex', flexDirection:'column', overflow:'hidden'}}>
{/* Editor top bar */}
<div style={{padding:'12px 20px', background:'#222',
borderBottom:'1px solid #2e2e2e', display:'flex',
alignItems:'center', gap: 16}}>
<div style={{display:'flex', gap: 8}}>
<div style={{width: 12, height: 12, borderRadius:'50%', background:'#ff5f57'}} />
<div style={{width: 12, height: 12, borderRadius:'50%', background:'#febc2e'}} />
<div style={{width: 12, height: 12, borderRadius:'50%', background:'#28c840'}} />
</div>
<div style={{fontFamily: mono, fontSize: 11, color:'#888',
letterSpacing: '0.1em'}}>
index.html · junior-pass-v0
</div>
</div>
{/* Code body */}
<div style={{flex: 1, padding:'32px 40px', fontFamily: mono,
fontSize: 19, lineHeight: 1.7, color:'#8a8a8a',
whiteSpace:'pre-wrap', position:'relative'}}>
<span style={{color:'#6a8a56'}}>{shownText}</span>
{cursorBlink && <span style={{background:'#c04a1a', display:'inline-block',
width: 10, height: 22, verticalAlign:'text-bottom'}} />}
</div>
{/* Bottom status bar */}
<div style={{padding:'10px 20px', background:'#161616',
borderTop:'1px solid #2a2a2a', display:'flex',
justifyContent:'space-between', fontFamily: mono, fontSize: 10,
color:'#666', letterSpacing:'0.12em'}}>
<span>HTML · COMMENTS ONLY</span>
<span>LN {Math.min(15, Math.floor(charCount / 30) + 1)} / 15</span>
</div>
</div>
{/* Callout */}
<div style={{position:'absolute', right: 120, bottom: 100,
opacity: hintOp, transform: `translateY(${hintBob}px)`,
display:'flex', alignItems:'center', gap: 14}}>
<svg width="48" height="24" viewBox="0 0 48 24">
<path d="M 46 12 L 6 12 M 14 6 L 6 12 L 14 18"
fill="none" stroke={TERRA} strokeWidth="2" strokeLinecap="round"/>
</svg>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
color: TERRA, letterSpacing:'0.02em'}}>
还没写一行渲染代码
</div>
</div>
</div>
);
}
// ── Scene 3: First show to user (8 – 13s) ─────────────────
function Scene3_FirstShow() {
const { elapsed } = useSprite();
const opacity = interpolate(elapsed, [0, 0.5], [0, 1]);
const fadeOut = interpolate(elapsed, [4.6, 5], [1, 0]);
// Wireframe fade in
const wireOp = interpolate(elapsed, [0.2, 0.9], [0, 1]);
// Chat bubbles stagger in
const b1Op = interpolate(elapsed, [1.0, 1.5], [0, 1]);
const b2Op = interpolate(elapsed, [2.0, 2.5], [0, 1]);
const b3Op = interpolate(elapsed, [2.9, 3.4], [0, 1]);
// Bottom subtitle
const subOp = interpolate(elapsed, [3.8, 4.3], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: opacity * fadeOut,
padding:'60px 100px', display:'flex', flexDirection:'column'}}>
{/* Header */}
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 28}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing: '0.3em', marginBottom: 6}}>STEP 2 / 3</div>
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
第一次 <span style={{fontStyle:'italic', color: TERRA}}>show</span> 给用户
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign:'right', lineHeight: 1.5}}>
「先给你看方向 —— 对吗?」<br/>
<span style={{fontSize: 14}}>— 10 分钟对齐</span>
</div>
</div>
{/* Two panels */}
<div style={{display:'grid', gridTemplateColumns:'1fr 1px 1.1fr', gap: 40,
flex: 1, alignItems:'stretch'}}>
{/* Left: wireframe */}
<div style={{background:'#fff', border: `1px solid ${LINE}`,
padding: 28, opacity: wireOp, display:'flex', flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.2em', marginBottom: 14}}>
WIREFRAME · NO STYLING
</div>
{/* Hero placeholder */}
<div style={{height: 180, background:'#ececec', border:'1.5px dashed #bbb',
display:'flex', alignItems:'center', justifyContent:'center',
fontFamily: mono, fontSize: 13, color:'#888',
letterSpacing:'0.15em', marginBottom: 20}}>
[ HERO IMAGE ]
</div>
{/* Title placeholder */}
<div style={{height: 26, background:'#e5e0d3', width:'75%',
marginBottom: 10}} />
<div style={{height: 16, background:'#ede8db', width:'55%',
marginBottom: 24}} />
{/* AI insight block */}
<div style={{background:'#f5f0e3', padding:'16px 18px', marginBottom: 20,
border: `1px dashed ${LINE}`}}>
<div style={{fontFamily: mono, fontSize: 9, color: ASH,
letterSpacing:'0.2em', marginBottom: 8}}>[ AI INSIGHT · 3 行 ]</div>
<div style={{height: 8, background:'#d9d2c5', width:'92%', marginBottom: 6}} />
<div style={{height: 8, background:'#d9d2c5', width:'88%', marginBottom: 6}} />
<div style={{height: 8, background:'#d9d2c5', width:'70%'}} />
</div>
<div style={{fontFamily: serif, fontSize: 14, color:'#888',
fontStyle:'italic', marginTop:'auto', textAlign:'center',
paddingTop: 14, borderTop: `1px solid ${LINE}`}}>
Marcus Aurelius · 第四卷
</div>
</div>
{/* Divider */}
<div style={{background: LINE, width: 1}} />
{/* Right: chat */}
<div style={{display:'flex', flexDirection:'column', gap: 18,
paddingTop: 10}}>
{/* Designer bubble */}
<div style={{opacity: b1Op, display:'flex', gap: 14,
alignItems:'flex-start'}}>
<div style={{width: 40, height: 40, background: TERRA, color:'#fff',
fontFamily: serif, fontSize: 16, fontWeight: 600,
display:'flex', alignItems:'center', justifyContent:'center',
flexShrink: 0, borderRadius: 2, letterSpacing:'0.05em'}}>JD</div>
<div style={{background:'#fff', border: `1px solid ${LINE}`,
padding:'16px 20px', maxWidth: '85%'}}>
<div style={{fontFamily: mono, fontSize: 9, color: ASH,
letterSpacing:'0.2em', marginBottom: 8}}>
JUNIOR DESIGNER
</div>
<div style={{fontFamily: serif, fontSize: 19, color: INK,
lineHeight: 1.5}}>
我对方向有几个假设,先给你看线框——想法对吗?
</div>
</div>
</div>
{/* User bubble 1 */}
<div style={{opacity: b2Op, display:'flex', gap: 14,
alignItems:'flex-start', justifyContent:'flex-end'}}>
<div style={{background: OLIVE, color:'#fff',
padding:'14px 20px', maxWidth: '80%'}}>
<div style={{fontFamily: mono, fontSize: 9,
color:'rgba(255,255,255,0.6)', letterSpacing:'0.2em',
marginBottom: 8}}>USER · FEEDBACK</div>
<div style={{fontFamily: serif, fontSize: 18, lineHeight: 1.5}}>
橙调 OK。AI 洞察那块我想要 <span style={{fontStyle:'italic',
textDecoration:'underline'}}>两行</span> 不要三行
</div>
</div>
</div>
{/* User bubble 2 */}
<div style={{opacity: b3Op, display:'flex', gap: 14,
alignItems:'flex-start', justifyContent:'flex-end'}}>
<div style={{background: OLIVE, color:'#fff',
padding:'14px 20px', maxWidth: '80%'}}>
<div style={{fontFamily: serif, fontSize: 18, lineHeight: 1.5}}>
Hero 图要 <span style={{fontStyle:'italic',
textDecoration:'underline'}}>摄影</span> 不要插画
</div>
</div>
</div>
{/* Subtitle */}
<div style={{marginTop:'auto', paddingTop: 24, opacity: subOp,
borderTop: `1px solid ${LINE}`}}>
<div style={{fontFamily: mono, fontSize: 11,
color: TERRA, letterSpacing:'0.3em', marginBottom: 6}}>
ALIGNMENT
</div>
<div style={{fontFamily: serif, fontSize: 30, fontWeight: 500,
color: INK, lineHeight: 1.2}}>
对齐 = <span style={{color: TERRA}}>10 分钟</span>
<span style={{fontStyle:'italic', color: ASH, fontSize: 22}}> · 不是 2 小时</span>
</div>
</div>
</div>
</div>
</div>
);
}
// ── ArtBlock: oil painting hero (from c1-ios-prototype) ───
function ArtBlock({ mood = 'warm' }) {
const palettes = {
warm: ['#8b4a2b', '#c67b4a', '#e3a876', '#f2d4a7'],
quiet: ['#3d4a3a', '#6a8066', '#a8b89c', '#e0d8b8'],
};
const p = palettes[mood];
return (
<div style={{
width:'100%', height:'100%', position:'relative', overflow:'hidden',
background: `linear-gradient(135deg, ${p[0]} 0%, ${p[1]} 35%, ${p[2]} 70%, ${p[3]} 100%)`,
}}>
<div style={{
position:'absolute', inset: 0,
background: `
radial-gradient(ellipse 80px 30px at 30% 40%, ${p[3]}44, transparent 70%),
radial-gradient(ellipse 60px 20px at 70% 60%, ${p[0]}33, transparent 70%),
radial-gradient(ellipse 100px 40px at 50% 80%, ${p[2]}44, transparent 70%),
radial-gradient(ellipse 50px 25px at 20% 70%, ${p[1]}55, transparent 70%)
`,
filter:'blur(1px)',
}} />
<svg width="100%" height="100%" style={{position:'absolute', inset:0, opacity: 0.18}}>
<filter id="paint-noise-w2">
<feTurbulence baseFrequency="0.9" numOctaves="2" />
<feColorMatrix values="0 0 0 0 0.3 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 1 0" />
</filter>
<rect width="100%" height="100%" filter="url(#paint-noise-w2)" />
</svg>
</div>
);
}
// ── Scene 4: Full pass fills in (13 – 18s) ────────────────
function Scene4_FullPass() {
const { elapsed } = useSprite();
const opacity = interpolate(elapsed, [0, 0.5], [0, 1]);
const fadeOut = interpolate(elapsed, [4.6, 5], [1, 0]);
// Staggered reveal of real content
const heroReveal = interpolate(elapsed, [0.6, 1.8], [0, 1], Easing.easeOut);
const titleReveal = interpolate(elapsed, [1.5, 2.3], [0, 1]);
const insightReveal = interpolate(elapsed, [2.2, 3.1], [0, 1]);
const meta = interpolate(elapsed, [3.0, 3.8], [0, 1]);
// Bottom subtitle
const subOp = interpolate(elapsed, [3.9, 4.4], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: opacity * fadeOut,
padding:'60px 100px', display:'flex', flexDirection:'column'}}>
{/* Header */}
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 28}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing: '0.3em', marginBottom: 6}}>STEP 3 / 3</div>
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
Full pass · <span style={{fontStyle:'italic', color: TERRA}}>灰块变真图</span>
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign:'right', lineHeight: 1.5}}>
Placeholders → Content<br/>
<span style={{fontSize: 14}}>— 方向对了再填色</span>
</div>
</div>
{/* Two panels: before wireframe (thumbnail) vs after (full card) */}
<div style={{display:'grid', gridTemplateColumns:'0.55fr 60px 1fr', gap: 0,
flex: 1, alignItems:'center'}}>
{/* Left: wireframe thumbnail, still, for contrast */}
<div style={{background:'#fff', border: `1px solid ${LINE}`,
padding: 20, opacity: 0.55, transform:'scale(0.92)',
transformOrigin:'center right'}}>
<div style={{fontFamily: mono, fontSize: 9, color: ASH,
letterSpacing:'0.2em', marginBottom: 10}}>
BEFORE
</div>
<div style={{height: 100, background:'#ececec',
border:'1.5px dashed #bbb', display:'flex',
alignItems:'center', justifyContent:'center',
fontFamily: mono, fontSize: 10, color:'#888',
letterSpacing:'0.15em', marginBottom: 12}}>
[ HERO IMAGE ]
</div>
<div style={{height: 14, background:'#e5e0d3', width:'75%', marginBottom: 6}} />
<div style={{height: 10, background:'#ede8db', width:'55%', marginBottom: 14}} />
<div style={{background:'#f5f0e3', padding:'10px 12px',
border: `1px dashed ${LINE}`}}>
<div style={{height: 6, background:'#d9d2c5', width:'92%', marginBottom: 4}} />
<div style={{height: 6, background:'#d9d2c5', width:'88%', marginBottom: 4}} />
<div style={{height: 6, background:'#d9d2c5', width:'70%'}} />
</div>
</div>
{/* Arrow */}
<div style={{display:'flex', alignItems:'center', justifyContent:'center'}}>
<svg width="40" height="24" viewBox="0 0 40 24">
<path d="M 2 12 L 38 12 M 30 4 L 38 12 L 30 20"
fill="none" stroke={TERRA} strokeWidth="2" strokeLinecap="round"/>
</svg>
</div>
{/* Right: full card */}
<div style={{background:'#fff', border: `1px solid ${LINE}`,
boxShadow:'0 20px 60px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.04)',
display:'flex', flexDirection:'column', overflow:'hidden'}}>
<div style={{padding:'14px 24px', borderBottom:`1px solid ${LINE}`,
display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<div style={{fontFamily: mono, fontSize: 10, color: TERRA,
letterSpacing:'0.25em'}}>AFTER · FULL PASS</div>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.15em'}}>STOIC READER · v0.3</div>
</div>
{/* Hero image: oil painting */}
<div style={{height: 220, position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', inset: 0, opacity: heroReveal}}>
<ArtBlock mood="warm" />
</div>
{heroReveal < 0.95 && (
<div style={{position:'absolute', inset: 0, background:'#ececec',
opacity: 1 - heroReveal, display:'flex', alignItems:'center',
justifyContent:'center', fontFamily: mono, fontSize: 12,
color:'#888', letterSpacing:'0.15em',
border:'1.5px dashed #bbb'}}>
[ HERO IMAGE ]
</div>
)}
{/* Photo credit */}
<div style={{position:'absolute', bottom: 10, right: 12,
fontFamily: mono, fontSize: 9, color:'rgba(255,255,255,0.7)',
letterSpacing:'0.2em', opacity: heroReveal}}>
PHOTO · J. TURNER
</div>
</div>
{/* Title */}
<div style={{padding:'26px 32px 10px', opacity: titleReveal}}>
<div style={{fontFamily: serif, fontSize: 36, fontWeight: 500,
color: INK, lineHeight: 1.15, letterSpacing:'-0.01em',
marginBottom: 6}}>
The Obstacle<br/>
<span style={{fontStyle:'italic', color: TERRA}}>is the Way</span>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
color: ASH}}>
Marcus Aurelius · 第四卷 · 今日阅读
</div>
</div>
{/* AI insight — NOW 2 lines, not 3 */}
<div style={{margin:'10px 32px 20px', padding:'16px 20px',
background:'#faf6ef', borderLeft: `3px solid ${TERRA}`,
opacity: insightReveal}}>
<div style={{fontFamily: mono, fontSize: 9, color: TERRA,
letterSpacing:'0.25em', marginBottom: 8}}>AI INSIGHT · 2 LINES</div>
<div style={{fontFamily: serif, fontSize: 15, color: INK,
lineHeight: 1.55}}>
你最近笔记里「困难」出现 7 次。<br/>
Aurelius 今天正好在说:障碍本身就是路。
</div>
</div>
{/* Meta row */}
<div style={{padding:'14px 32px', borderTop:`1px solid ${LINE}`,
display:'flex', justifyContent:'space-between',
fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing:'0.15em', opacity: meta}}>
<span>READ · 4 MIN</span>
<span>MEMORY · 12 NOTES</span>
<span>CHAT · ASK AURELIUS</span>
</div>
</div>
</div>
{/* Subtitle */}
<div style={{textAlign:'center', marginTop: 30, opacity: subOp}}>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
color: INK}}>
迭代到 80%,再做最后 20% 的抛光
</div>
</div>
</div>
);
}
// ── Scene 5: Closing + 4 checkpoints (18 – 22s) ───────────
function Scene5_Closing() {
const { elapsed } = useSprite();
const opacity = interpolate(elapsed, [0, 0.6], [0, 1]);
const titleY = interpolate(elapsed, [0, 1.0], [30, 0], Easing.easeOut);
const lineW = interpolate(elapsed, [0.8, 1.6], [0, 620]);
const items = [
{ text: '问 clarifying questions(一次一批)' },
{ text: '写 assumptions + placeholders' },
{ text: '尽早 show(哪怕只是灰块)' },
{ text: '迭代再抛光,不一次做完' },
];
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column', padding:'60px 120px'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 24}}>
JUNIOR DESIGNER · RECAP
</div>
<div style={{fontFamily: serif, fontSize: 100, fontWeight: 500,
color: INK, lineHeight: 1.05, letterSpacing:'-0.015em',
transform: `translateY(${titleY}px)`, textAlign:'center'}}>
早 <span style={{fontStyle:'italic', color: TERRA}}>show</span>
<span style={{color: ASH, fontWeight: 400, margin:'0 18px'}}>·</span>
早 <span style={{fontStyle:'italic', color: TERRA}}>改</span>
<span style={{color: ASH, fontWeight: 400, margin:'0 18px'}}>·</span>
早 <span style={{fontStyle:'italic', color: TERRA}}>对齐</span>
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 34,
marginBottom: 46}} />
{/* 4 checkpoints */}
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:'18px 60px',
maxWidth: 1100, width:'100%'}}>
{items.map((it, i) => {
const appear = interpolate(elapsed, [1.6 + i * 0.25, 2.2 + i * 0.25], [0, 1]);
const checkAppear = interpolate(elapsed, [2.0 + i * 0.25, 2.5 + i * 0.25], [0, 1], Easing.spring);
return (
<div key={i} style={{display:'flex', alignItems:'center', gap: 20,
opacity: appear, borderBottom:`1px solid ${LINE}`, paddingBottom: 14}}>
<div style={{width: 36, height: 36, border:`2px solid ${TERRA}`,
display:'flex', alignItems:'center', justifyContent:'center',
flexShrink: 0, background: checkAppear > 0.5 ? TERRA : 'transparent',
color:'#fff', fontFamily: serif, fontWeight: 600, fontSize: 20,
lineHeight: 1, transform: `scale(${0.7 + checkAppear * 0.3})`}}>
{checkAppear > 0.5 ? '✓' : ''}
</div>
<div style={{fontFamily: serif, fontSize: 26, color: INK,
lineHeight: 1.35, letterSpacing:'0.005em'}}>
{it.text}
</div>
</div>
);
})}
</div>
</div>
);
}
// ── Watermark ─────────────────────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
// ── Main composition ──────────────────────────────────────
function App() {
return (
<Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3}><Scene1_Contrast /></Sprite>
<Sprite start={3} end={8}><Scene2_Skeleton /></Sprite>
<Sprite start={8} end={13}><Scene3_FirstShow /></Sprite>
<Sprite start={13} end={18}><Scene4_FullPass /></Sprite>
<Sprite start={18} end={22}><Scene5_Closing /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
+747
View File
@@ -0,0 +1,747 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Huashu-Design · Fallback 设计顾问</title>
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; }
body {
background: #0c0c0c;
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>
</head>
<body>
<div id="root"></div>
<!-- animations.jsx inlined -->
<script type="text/babel">
(function() {
const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
const TimeContext = createContext({ time: 0, duration: 10, playing: false });
const SpriteContext = createContext(null);
const Easing = {
linear: t => t,
easeIn: t => t * t,
easeOut: t => 1 - (1 - t) * (1 - t),
easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
spring: t => {
const c = (2 * Math.PI) / 3;
return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
},
};
function interpolate(t, input, output, easing) {
const [inStart, inEnd] = input;
const [outStart, outEnd] = output;
if (t <= inStart) return outStart;
if (t >= inEnd) return outEnd;
let progress = (t - inStart) / (inEnd - inStart);
if (easing) progress = easing(progress);
return outStart + (outEnd - outStart) * progress;
}
function useTime() { return useContext(TimeContext).time; }
function useSprite() {
const sprite = useContext(SpriteContext);
return sprite || { t: 0, elapsed: 0, duration: 0 };
}
function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
const [time, setTime] = useState(0);
const [playing, setPlaying] = useState(true);
const [scale, setScale] = useState(1);
const rafRef = useRef(null);
const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
useEffect(() => {
function updateScale() {
const vw = window.innerWidth;
const vh = window.innerHeight - 56;
const s = Math.min(vw / width, vh / height);
setScale(s);
}
updateScale();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, [width, height]);
useEffect(() => {
if (!playing) return;
let cancelled = false;
let last = null;
function tick(now) {
if (cancelled) return;
if (last === null) {
last = now;
if (typeof window !== 'undefined') window.__ready = true;
}
const delta = (now - last) / 1000;
last = now;
setTime(prev => {
const next = prev + delta;
if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
return next;
});
rafRef.current = requestAnimationFrame(tick);
}
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
}, [playing, duration, effectiveLoop]);
const progress = time / duration;
const ctx = { time, duration, playing, setPlaying, setTime };
const canvasStyle = {
position: 'absolute',
top: '50%',
left: '50%',
transformOrigin: 'center center',
width,
height,
background: bgColor,
overflow: 'hidden',
transform: `translate(-50%, -50%) scale(${scale})`,
};
return (
<TimeContext.Provider value={ctx}>
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
<div style={canvasStyle}>{children}</div>
</div>
<div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
<button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
<button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
</div>
</div>
</div>
</TimeContext.Provider>
);
}
function Sprite({ start = 0, end, children, style }) {
const { time } = useContext(TimeContext);
const actualEnd = end == null ? Infinity : end;
if (time < start || time >= actualEnd) return null;
const duration = actualEnd - start;
const elapsed = time - start;
const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
const spriteValue = { t, elapsed, duration, start, end: actualEnd };
return (
<SpriteContext.Provider value={spriteValue}>
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
</SpriteContext.Provider>
);
}
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
})();
</script>
<!-- Demo scene -->
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
// ── Design tokens ─────────────────────────────────────────
const CREAM = '#FAF6EF';
const INK = '#1a1a1a';
const TERRA = '#C04A1A';
const ASH = '#6b6b6b';
const LINE = '#d9d2c5';
// ── 20 design philosophies ────────────────────────────────
const PHILOSOPHIES = [
{ n: 'Pentagram', school: '信息建筑', en: 'INFO-ARCH' },
{ n: 'Massimo Vignelli', school: '信息建筑', en: 'INFO-ARCH' },
{ n: 'Dieter Rams', school: '信息建筑', en: 'INFO-ARCH' },
{ n: 'Otl Aicher', school: '信息建筑', en: 'INFO-ARCH' },
{ n: 'Field.io', school: '运动诗学', en: 'KINETIC' },
{ n: 'Active Theory', school: '运动诗学', en: 'KINETIC' },
{ n: 'Locomotive', school: '运动诗学', en: 'KINETIC' },
{ n: 'Joshua Davis', school: '运动诗学', en: 'KINETIC' },
{ n: 'Kenya Hara', school: '东方哲学', en: 'EASTERN' },
{ n: 'Naoto Fukasawa', school: '东方哲学', en: 'EASTERN' },
{ n: 'Kashiwa Sato', school: '东方哲学', en: 'EASTERN' },
{ n: 'John Maeda', school: '东方哲学', en: 'EASTERN' },
{ n: 'Sagmeister', school: '实验先锋', en: 'AVANT' },
{ n: 'David Carson', school: '实验先锋', en: 'AVANT' },
{ n: 'Paula Scher', school: '实验先锋', en: 'AVANT' },
{ n: 'Tomato', school: '实验先锋', en: 'AVANT' },
{ n: 'Dan Flavin', school: '极简主义', en: 'MINIMAL' },
{ n: 'Ryuichi Sakamoto', school: '极简主义', en: 'MINIMAL' },
{ n: 'Agnes Martin', school: '极简主义', en: 'MINIMAL' },
{ n: 'Donald Judd', school: '极简主义', en: 'MINIMAL' },
];
const SELECTED_INDICES = [0, 4, 8]; // Pentagram, Field.io, Kenya Hara
// ── Shared typography helpers ─────────────────────────────
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
const sans = "'Inter', -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
// ── Scene 1: Vague brief (0 – 3.5s) ───────────────────────
function Scene1_VagueBrief() {
const { t, elapsed } = useSprite();
const charCount = Math.floor(interpolate(elapsed, [0.3, 1.8], [0, 9]));
const text = '做个好看的页面'.slice(0, charCount);
const cursorBlink = Math.floor(elapsed * 2.4) % 2 === 0;
const questionOpacity = interpolate(elapsed, [1.8, 2.4], [0, 1]);
const questionBob = Math.sin(elapsed * 4) * 6;
const fadeOut = interpolate(elapsed, [2.8, 3.5], [1, 0], Easing.easeIn);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: sans, fontSize:14, letterSpacing:'0.3em',
color: ASH, marginBottom: 40}}>
用户需求
</div>
<div style={{display:'flex', alignItems:'flex-start', gap: 36}}>
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 120,
color: TERRA, lineHeight: 1, marginTop: -20}}>「</div>
<div style={{fontFamily: serif, fontSize: 96, fontWeight: 400,
color: INK, letterSpacing: '0.02em', position: 'relative'}}>
{text}
<span style={{opacity: cursorBlink ? 1 : 0, color: TERRA,
marginLeft: 4, fontWeight: 300}}>|</span>
</div>
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 120,
color: TERRA, lineHeight: 1, marginTop: -20}}>」</div>
</div>
<div style={{fontFamily: sans, fontSize: 20, color: ASH, marginTop: 60,
opacity: questionOpacity, transform: `translateY(${questionBob}px)`,
letterSpacing: '0.05em'}}>
<span style={{color: TERRA, fontSize: 28, marginRight: 12}}>?</span>
风格、受众、情感基调—— 都没说
</div>
</div>
);
}
// ── Scene 2: Advisor activates (3.5 – 6.5s) ───────────────
function Scene2_AdvisorIntro() {
const { elapsed } = useSprite();
const mainY = interpolate(elapsed, [0, 1.2], [40, 0], Easing.easeOut);
const mainOpacity = interpolate(elapsed, [0, 0.8], [0, 1]);
const lineWidth = interpolate(elapsed, [0.8, 1.8], [0, 320]);
const subOpacity = interpolate(elapsed, [1.2, 2], [0, 1]);
const fadeOut = interpolate(elapsed, [2.5, 3], [1, 0]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
<div style={{fontFamily: sans, fontSize: 12, letterSpacing: '0.4em',
color: TERRA, marginBottom: 24, opacity: mainOpacity}}>
设计方向顾问 · Fallback
</div>
<div style={{fontFamily: serif, fontSize: 132, fontWeight: 500,
color: INK, lineHeight: 1, letterSpacing: '-0.01em',
opacity: mainOpacity, transform: `translateY(${mainY}px)`}}>
推荐 <span style={{fontStyle:'italic', color: TERRA}}>3</span> 个方向
</div>
<div style={{height: 1, background: INK, width: lineWidth, marginTop: 36}} />
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 26,
color: ASH, marginTop: 28, opacity: subOpacity}}>
从 20 种设计哲学里,按 5 个不同流派差异化推荐
</div>
</div>
);
}
// ── Scene 3: 20 philosophies grid scan (6.5 – 10.5s) ──────
function Scene3_GridScan() {
const { elapsed } = useSprite();
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
return (
<div style={{position:'absolute', inset:0, background:CREAM,
padding: '80px 120px', display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', opacity: titleOp, marginBottom: 50}}>
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
设计哲学库
</div>
<div style={{fontFamily: mono, fontSize: 14, color: ASH, letterSpacing:'0.1em'}}>
20 位设计师 · 5 个流派
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'repeat(5, 1fr)', gap: 20, flex: 1}}>
{PHILOSOPHIES.map((p, i) => {
const stagger = i * 0.06;
const appearT = Math.max(0, Math.min(1, (elapsed - 0.5 - stagger) / 0.4));
const op = appearT;
const ty = (1 - appearT) * 24;
// Scanner highlight: sweeps through 20 cards from t=2.2 to t=3.2
const scannerStart = 2.2 + i * 0.04;
const scannerEnd = scannerStart + 0.25;
const scanHighlight = elapsed > scannerStart && elapsed < scannerEnd ? 1 : 0;
// Selected cards get circled at t=3.3+
const isSelected = SELECTED_INDICES.includes(i);
const selectT = Math.max(0, Math.min(1, (elapsed - 3.3) / 0.5));
const selectOp = isSelected ? selectT : 0;
const selectDim = !isSelected && elapsed > 3.4 ? interpolate(elapsed, [3.4, 3.8], [1, 0.28]) : 1;
return (
<div key={i} style={{
opacity: op * selectDim,
transform: `translateY(${ty}px)`,
background: scanHighlight ? '#fff' : 'transparent',
border: `1px solid ${isSelected && selectT > 0.3 ? TERRA : LINE}`,
borderWidth: isSelected && selectT > 0.3 ? 2 : 1,
padding: '20px 18px',
position: 'relative',
transition: 'none',
}}>
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
letterSpacing: '0.15em', marginBottom: 10}}>
{String(i+1).padStart(2,'0')} · {p.en}
</div>
<div style={{fontFamily: serif, fontSize: 22, fontWeight: 500,
color: INK, lineHeight: 1.15, marginBottom: 6}}>
{p.n}
</div>
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 14,
color: ASH}}>
{p.school}
</div>
{isSelected && selectT > 0.4 && (
<div style={{position:'absolute', top: -10, right: -10,
width: 26, height: 26, borderRadius: '50%', background: TERRA,
color: '#fff', display:'flex', alignItems:'center',
justifyContent:'center', fontFamily: serif, fontSize: 14,
fontWeight: 600, opacity: selectOp}}>
{SELECTED_INDICES.indexOf(i) + 1}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// ── Scene 4: Three-panel parallel demo generation (10.5 – 19s) ──
function Scene4_ParallelDemos() {
const { elapsed } = useSprite();
const slideIn = interpolate(elapsed, [0, 1], [200, 0], Easing.easeOut);
const opacity = interpolate(elapsed, [0, 0.6], [0, 1]);
const panels = [
{ name: 'Pentagram', school: '信息建筑派', en: 'Information Architecture',
delay: 0, render: 'pentagram' },
{ name: 'Field.io', school: '运动诗学派', en: 'Kinetic Poetry',
delay: 0.3, render: 'field' },
{ name: 'Kenya Hara', school: '东方哲学派', en: 'Eastern Minimalism',
delay: 0.6, render: 'hara' },
];
return (
<div style={{position:'absolute', inset:0, background:CREAM,
padding: '60px 60px 40px', display:'flex', flexDirection:'column',
opacity}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 28}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing: '0.3em', marginBottom: 4}}>步骤 3 / 4</div>
<div style={{fontFamily: serif, fontSize: 44, fontWeight: 500, color: INK}}>
并行生成视觉 Demo
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
color: ASH, textAlign: 'right'}}>
"看到比说到更有效"<br/>
<span style={{fontSize: 14}}>— 设计顾问模式 · Phase 5</span>
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap: 24,
flex: 1, transform: `translateY(${slideIn}px)`}}>
{panels.map((p, i) => (
<DemoPanel key={i} panel={p} localElapsed={elapsed - p.delay} />
))}
</div>
</div>
);
}
function DemoPanel({ panel, localElapsed }) {
const progressT = Math.max(0, Math.min(1, localElapsed / 3.0));
const progressPct = progressT * 100;
const done = progressT >= 0.92;
// Content fades in during the last 0.7s of generation — overlaps with
// skeleton fade-out so there's no empty-canvas gap when "READY" appears.
const contentReveal = interpolate(localElapsed, [2.4, 3.2], [0, 1], Easing.easeOut);
const skeletonOp = interpolate(localElapsed, [2.4, 3.2], [1, 0], Easing.easeOut);
return (
<div style={{
background:'#fff',
border: `1px solid ${LINE}`,
display:'flex', flexDirection:'column',
position:'relative',
}}>
{/* Header */}
<div style={{padding: '18px 22px', borderBottom: `1px solid ${LINE}`,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500, color: INK}}>
{panel.name}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
color: ASH, marginTop: 2}}>
{panel.en}
</div>
</div>
<div style={{fontFamily: mono, fontSize: 10, color: done ? TERRA : ASH,
letterSpacing: '0.15em'}}>
{done ? '✓ READY' : 'GENERATING'}
</div>
</div>
{/* Canvas */}
<div style={{flex: 1, position: 'relative', overflow: 'hidden'}}>
{skeletonOp > 0.02 && (
<div style={{position:'absolute', inset:0, opacity: skeletonOp}}>
<GenerationSkeleton progress={progressT} />
</div>
)}
<div style={{position:'absolute', inset:0, opacity: contentReveal}}>
{panel.render === 'pentagram' && <PentagramDemo />}
{panel.render === 'field' && <FieldDemo elapsed={localElapsed - 3.2} />}
{panel.render === 'hara' && <HaraDemo />}
</div>
</div>
{/* Progress bar */}
<div style={{height: 2, background: '#eee', position: 'relative'}}>
<div style={{position:'absolute', top:0, left:0, height:'100%',
width: `${progressPct}%`, background: TERRA,
transition:'none'}} />
</div>
</div>
);
}
function GenerationSkeleton({ progress }) {
const bars = [60, 85, 40, 72, 90, 55, 68];
return (
<div style={{padding: 24, display:'flex', flexDirection:'column', gap: 14}}>
{bars.map((w, i) => (
<div key={i} style={{height: 10, width: `${w}%`,
background: `linear-gradient(90deg, ${LINE} 0%, ${LINE} ${100-progress*80}%, #fff ${100-progress*80}%)`,
opacity: 0.6 + progress * 0.4}} />
))}
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
marginTop: 20, letterSpacing:'0.1em'}}>
{progress < 0.3 && '▸ loading style tokens...'}
{progress >= 0.3 && progress < 0.6 && '▸ composing layout...'}
{progress >= 0.6 && progress < 0.9 && '▸ applying typography...'}
{progress >= 0.9 && '▸ finalizing...'}
</div>
</div>
);
}
// ── Pentagram demo: serif editorial, strict grid, monochrome ──
function PentagramDemo() {
return (
<div style={{padding: '28px 28px 24px', background:'#fafafa', height:'100%',
display:'flex', flexDirection:'column', fontFamily: serif, color:'#111'}}>
<div style={{display:'flex', justifyContent:'space-between',
borderBottom:'1px solid #111', paddingBottom: 10, marginBottom: 16,
fontFamily: mono, fontSize: 9, letterSpacing:'0.2em'}}>
<span>VOL. 01 · MMXXVI</span>
<span>NO. 043</span>
</div>
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
color:'#888', marginBottom: 10}}>ESSAY</div>
<div style={{fontSize: 40, lineHeight: 1.05, fontWeight: 500,
letterSpacing: '-0.02em', marginBottom: 18}}>
A Pure<br/>
<span style={{fontStyle:'italic'}}>Information</span><br/>
Architecture
</div>
<div style={{height: 1, background:'#111', margin:'8px 0 14px'}} />
<div style={{fontSize: 13, lineHeight: 1.55, color:'#333', flex: 1}}>
Designed not to impress, but to inform. The grid carries meaning; typography does the work.
</div>
<div style={{borderTop:'1px solid #111', paddingTop: 10, marginTop: 14,
display:'flex', justifyContent:'space-between', fontFamily: mono,
fontSize: 9, letterSpacing:'0.2em', color:'#888'}}>
<span>NEW YORK</span>
<span>PENTAGRAM</span>
</div>
</div>
);
}
// ── Field.io demo: dark, kinetic geometric shapes ──
function FieldDemo({ elapsed }) {
const e = Math.max(0, elapsed || 0);
return (
<div style={{padding: 0, background:'#0e1016', height:'100%',
position:'relative', overflow:'hidden'}}>
<svg viewBox="0 0 400 500" width="100%" height="100%"
style={{position:'absolute', inset:0}} preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="fg1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#ff6a3d" />
<stop offset="100%" stopColor="#c04a1a" />
</linearGradient>
<linearGradient id="fg2" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#4a9eff" />
<stop offset="100%" stopColor="#1a4fc0" />
</linearGradient>
</defs>
{/* Concentric circles breathing */}
{[0, 1, 2, 3].map(i => (
<circle key={i} cx="200" cy="280"
r={40 + i * 50 + Math.sin(e * 1.2 + i) * 10}
fill="none" stroke="url(#fg1)" strokeWidth={1.5}
opacity={0.4 - i * 0.08} />
))}
{/* Rotating triangle */}
<g transform={`translate(200 280) rotate(${e * 20})`}>
<polygon points="0,-70 60,35 -60,35" fill="url(#fg2)" opacity="0.7" />
</g>
{/* Orbiting dots */}
{[0, 1, 2, 3, 4, 5].map(i => {
const angle = (e * 0.8 + i * Math.PI / 3);
return <circle key={i} cx={200 + Math.cos(angle) * 150}
cy={280 + Math.sin(angle) * 150} r={4} fill="#ff6a3d" opacity={0.9}/>;
})}
</svg>
<div style={{position:'absolute', top: 24, left: 24, right: 24,
display:'flex', justifyContent:'space-between',
fontFamily: mono, fontSize: 10, letterSpacing:'0.3em', color:'#fff', opacity: 0.7}}>
<span>FIELD.IO</span>
<span>LIVE · RECORDING</span>
</div>
<div style={{position:'absolute', bottom: 24, left: 24, right: 24,
fontFamily: serif, fontStyle:'italic', fontSize: 20, color:'#fff',
letterSpacing:'0.02em'}}>
kinetic identity<br/>
<span style={{fontFamily: mono, fontSize: 10, fontStyle:'normal',
letterSpacing:'0.2em', color:'#ff6a3d', opacity: 0.8}}>
/ motion is the brand
</span>
</div>
</div>
);
}
// ── Kenya Hara demo: vast white space, tiny dot, haiku ──
function HaraDemo() {
return (
<div style={{padding: 0, background:'#fdfbf6', height:'100%',
position:'relative'}}>
<div style={{position:'absolute', top: 28, left: 32,
fontFamily: mono, fontSize: 10, letterSpacing:'0.3em', color:'#aaa'}}>
HARA · MMXXVI
</div>
<div style={{position:'absolute', top: '42%', left:'50%',
transform:'translate(-50%, -50%)', width: 14, height: 14,
borderRadius:'50%', background:'#1a1a1a'}} />
<div style={{position:'absolute', top:'58%', left:'50%',
transform:'translateX(-50%)', fontFamily: serif, fontStyle:'italic',
fontSize: 14, color:'#1a1a1a', letterSpacing:'0.1em'}}>
white.
</div>
<div style={{position:'absolute', bottom: 32, right: 32,
writingMode:'vertical-rl', fontFamily: "'Noto Serif SC', serif",
fontSize: 16, color:'#888', letterSpacing:'0.2em'}}>
原 研 哉
</div>
<div style={{position:'absolute', bottom: 28, left: 32,
fontFamily: serif, fontStyle:'italic', fontSize: 11, color:'#999',
maxWidth: 200, lineHeight: 1.6}}>
"Emptiness is not nothing—<br/>it is everything that could be."
</div>
</div>
);
}
// ── Scene 5: User selects Kenya Hara (19 – 22s) ───────────
function Scene5_Select() {
const { elapsed } = useSprite();
// Cursor travels from right edge toward middle panel
const cursorX = interpolate(elapsed, [0, 1.2], [1750, 960], Easing.easeInOut);
const cursorY = interpolate(elapsed, [0, 1.2], [240, 540], Easing.easeInOut);
const cursorOp = interpolate(elapsed, [0, 0.2], [0, 1]);
// Middle panel selection lock-in
const selectLock = Math.max(0, Math.min(1, (elapsed - 1.2) / 0.4));
// Left + right panels dim + shrink
const sideDim = interpolate(elapsed, [1.2, 1.8], [1, 0.2]);
const sideScale = interpolate(elapsed, [1.2, 1.8], [1, 0.92], Easing.easeOut);
// Middle scales up
const midScale = interpolate(elapsed, [1.2, 1.8], [1, 1.06], Easing.easeOut);
return (
<div style={{position:'absolute', inset:0, background:CREAM,
padding: '60px 60px 40px', display:'flex', flexDirection:'column'}}>
<div style={{display:'flex', justifyContent:'space-between',
alignItems:'baseline', marginBottom: 28}}>
<div>
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
letterSpacing: '0.3em', marginBottom: 4}}>步骤 4 / 4</div>
<div style={{fontFamily: serif, fontSize: 44, fontWeight: 500, color: INK}}>
用户选定方向
</div>
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH}}>
——或混合:"A 的配色 + C 的布局"
</div>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap: 24,
flex: 1}}>
<StaticPanel which="pentagram" opacity={sideDim} scale={sideScale} />
<StaticPanel which="hara" opacity={1} scale={midScale} selected={selectLock > 0.5}/>
<StaticPanel which="field" opacity={sideDim} scale={sideScale} />
</div>
{/* Cursor */}
<div style={{position:'absolute', left: cursorX, top: cursorY,
opacity: cursorOp, pointerEvents:'none', zIndex: 50,
filter:'drop-shadow(0 4px 8px rgba(0,0,0,0.2))'}}>
<svg width="36" height="44" viewBox="0 0 36 44">
<path d="M 2 2 L 2 38 L 11 30 L 16 42 L 22 40 L 17 28 L 28 28 Z"
fill="#fff" stroke="#1a1a1a" strokeWidth="2" strokeLinejoin="round"/>
</svg>
</div>
{/* "Selected" callout */}
{selectLock > 0.5 && (
<div style={{position:'absolute', left:'50%', top: 140,
transform:'translateX(-50%)', background: TERRA, color:'#fff',
padding:'10px 24px', fontFamily: mono, fontSize: 12,
letterSpacing:'0.25em', opacity: selectLock, zIndex: 40}}>
✓ SELECTED
</div>
)}
</div>
);
}
function StaticPanel({ which, opacity, scale, selected }) {
const titles = {
pentagram: { n: 'Pentagram', en: 'Information Architecture' },
hara: { n: 'Kenya Hara', en: 'Eastern Minimalism' },
field: { n: 'Field.io', en: 'Kinetic Poetry' },
};
const t = titles[which];
return (
<div style={{
background:'#fff',
border: selected ? `3px solid ${TERRA}` : `1px solid ${LINE}`,
opacity, transform: `scale(${scale})`, transformOrigin:'center center',
display:'flex', flexDirection:'column',
}}>
<div style={{padding: '18px 22px', borderBottom: `1px solid ${LINE}`,
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
<div>
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500, color: INK}}>
{t.n}
</div>
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
color: ASH, marginTop: 2}}>
{t.en}
</div>
</div>
<div style={{fontFamily: mono, fontSize: 10,
color: selected ? TERRA : '#999',
letterSpacing: '0.15em'}}>
{selected ? '✓ SELECTED' : 'READY'}
</div>
</div>
<div style={{flex: 1, position:'relative', overflow:'hidden'}}>
{which === 'pentagram' && <PentagramDemo />}
{which === 'field' && <FieldDemo elapsed={10} />}
{which === 'hara' && <HaraDemo />}
</div>
</div>
);
}
// ── Scene 6: Ready to execute (22 – 24s) ──────────────────
function Scene6_Final() {
const { elapsed } = useSprite();
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
const lineW = interpolate(elapsed, [0.6, 1.4], [0, 600]);
return (
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeIn,
display:'flex', alignItems:'center', justifyContent:'center',
flexDirection:'column'}}>
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
color: TERRA, marginBottom: 20}}>
NEXT · JUNIOR DESIGNER PASS
</div>
<div style={{fontFamily: serif, fontSize: 104, fontWeight: 500,
color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
开始 <span style={{fontStyle:'italic', color: TERRA}}>Kenya Hara</span> 风格
</div>
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
color: ASH, marginTop: 28, maxWidth: 700, textAlign:'center', lineHeight: 1.5}}>
"方向确认 → 回到 Junior Designer 主干流程<br/>
这时已有明确的 design context,不再是凭空做"
</div>
</div>
);
}
// ── Watermark (always visible) ────────────────────────────
function Watermark() {
return (
<div style={{position:'absolute', bottom: 24, right: 32,
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
Created by Huashu-Design
</div>
);
}
// ── Main composition ──────────────────────────────────────
function App() {
return (
<Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
<Sprite start={0} end={3.5}><Scene1_VagueBrief /></Sprite>
<Sprite start={3.5} end={6.5}><Scene2_AdvisorIntro /></Sprite>
<Sprite start={6.5} end={10.5}><Scene3_GridScan /></Sprite>
<Sprite start={10.5} end={19}><Scene4_ParallelDemos /></Sprite>
<Sprite start={19} end={22}><Scene5_Select /></Sprite>
<Sprite start={22} end={24}><Scene6_Final /></Sprite>
<Watermark />
</Stage>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>