c1-ios-prototype.html 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>Huashu-Design · iOS App Prototype</title>
  6. <script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
  7. <script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
  8. <script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
  9. <link rel="preconnect" href="https://fonts.googleapis.com">
  10. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  11. <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">
  12. <style>
  13. * { box-sizing: border-box; margin: 0; padding: 0; }
  14. html, body { width: 100%; height: 100%; overflow: hidden; }
  15. body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
  16. </style>
  17. </head>
  18. <body>
  19. <div id="root"></div>
  20. <!-- animations.jsx inlined -->
  21. <script type="text/babel">
  22. (function() {
  23. const { createContext, useContext, useState, useEffect, useRef } = React;
  24. const TimeContext = createContext({ time: 0, duration: 10, playing: false });
  25. const SpriteContext = createContext(null);
  26. const Easing = {
  27. linear: t => t,
  28. easeIn: t => t * t,
  29. easeOut: t => 1 - (1 - t) * (1 - t),
  30. easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
  31. spring: t => {
  32. const c = (2 * Math.PI) / 3;
  33. return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
  34. },
  35. };
  36. function interpolate(t, input, output, easing) {
  37. const [a, b] = input, [x, y] = output;
  38. if (t <= a) return x; if (t >= b) return y;
  39. let p = (t - a) / (b - a); if (easing) p = easing(p);
  40. return x + (y - x) * p;
  41. }
  42. function useTime() { return useContext(TimeContext).time; }
  43. function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
  44. function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
  45. const [time, setTime] = useState(0);
  46. const [playing, setPlaying] = useState(true);
  47. const [scale, setScale] = useState(1);
  48. const rafRef = useRef(null);
  49. const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
  50. useEffect(() => {
  51. const update = () => {
  52. const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
  53. setScale(s);
  54. };
  55. update(); window.addEventListener('resize', update);
  56. return () => window.removeEventListener('resize', update);
  57. }, [width, height]);
  58. useEffect(() => {
  59. if (!playing) return;
  60. let cancelled = false, last = null;
  61. function tick(now) {
  62. if (cancelled) return;
  63. if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
  64. const delta = (now - last) / 1000; last = now;
  65. setTime(prev => {
  66. const next = prev + delta;
  67. if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
  68. return next;
  69. });
  70. rafRef.current = requestAnimationFrame(tick);
  71. }
  72. const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
  73. if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
  74. return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
  75. }, [playing, duration, effectiveLoop]);
  76. const progress = time / duration;
  77. const ctx = { time, duration, playing, setPlaying, setTime };
  78. return (
  79. <TimeContext.Provider value={ctx}>
  80. <div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
  81. <div style={{flex:1, position:'relative', overflow:'hidden'}}>
  82. <div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
  83. {children}
  84. </div>
  85. </div>
  86. <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}}>
  87. <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>
  88. <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>
  89. <div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
  90. <div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
  91. <div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
  92. </div>
  93. </div>
  94. </div>
  95. </TimeContext.Provider>
  96. );
  97. }
  98. function Sprite({ start = 0, end, children, style }) {
  99. const { time } = useContext(TimeContext);
  100. const actualEnd = end == null ? Infinity : end;
  101. if (time < start || time >= actualEnd) return null;
  102. const duration = actualEnd - start;
  103. const elapsed = time - start;
  104. const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
  105. return (
  106. <SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
  107. <div style={{position:'absolute', inset:0, ...style}}>{children}</div>
  108. </SpriteContext.Provider>
  109. );
  110. }
  111. window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
  112. })();
  113. </script>
  114. <!-- Demo scene -->
  115. <script type="text/babel">
  116. const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
  117. // ── Design tokens ─────────────────────────────────────────
  118. const CREAM = '#FAF6EF';
  119. const PAPER = '#FDFBF5';
  120. const INK = '#1a1a1a';
  121. const TERRA = '#C04A1A';
  122. const OLIVE = '#6a6b4e';
  123. const ASH = '#6b6b6b';
  124. const LINE = '#e5ddcd';
  125. const LINE2 = '#d9d2c5';
  126. const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
  127. const sans = "'Inter', -apple-system, sans-serif";
  128. const mono = "'JetBrains Mono', ui-monospace, monospace";
  129. // ── Art image: CSS-rendered "oil painting" hero ──────────
  130. function ArtBlock({ mood = 'warm', height = 200 }) {
  131. // Three curated palettes for variety (no Unsplash dep, stable offline)
  132. const palettes = {
  133. warm: ['#8b4a2b', '#c67b4a', '#e3a876', '#f2d4a7'], // Turner sunset
  134. quiet: ['#3d4a3a', '#6a8066', '#a8b89c', '#e0d8b8'], // Corot pastoral
  135. study: ['#2a3552', '#5e6b8a', '#8b98b5', '#d4c9a5'], // Vermeer indoor
  136. };
  137. const p = palettes[mood];
  138. return (
  139. <div style={{
  140. width: '100%', height, position: 'relative', overflow: 'hidden',
  141. background: `linear-gradient(135deg, ${p[0]} 0%, ${p[1]} 35%, ${p[2]} 70%, ${p[3]} 100%)`,
  142. }}>
  143. {/* Impressionist brush texture */}
  144. <div style={{
  145. position: 'absolute', inset: 0,
  146. background: `
  147. radial-gradient(ellipse 80px 30px at 30% 40%, ${p[3]}44, transparent 70%),
  148. radial-gradient(ellipse 60px 20px at 70% 60%, ${p[0]}33, transparent 70%),
  149. radial-gradient(ellipse 100px 40px at 50% 80%, ${p[2]}44, transparent 70%),
  150. radial-gradient(ellipse 50px 25px at 20% 70%, ${p[1]}55, transparent 70%)
  151. `,
  152. filter: 'blur(1px)',
  153. }} />
  154. {/* Subtle scratch noise */}
  155. <svg width="100%" height="100%" style={{position:'absolute', inset:0, opacity: 0.18}}>
  156. <filter id="paint-noise">
  157. <feTurbulence baseFrequency="0.9" numOctaves="2" />
  158. <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" />
  159. </filter>
  160. <rect width="100%" height="100%" filter="url(#paint-noise)" />
  161. </svg>
  162. </div>
  163. );
  164. }
  165. // ── iOS Frame (simplified from ios_frame.jsx, positioned for demo) ──
  166. function IosFrame({ children, time = '9:41', scale = 1, style = {} }) {
  167. const W = 420, H = 900;
  168. return (
  169. <div style={{
  170. display: 'inline-block',
  171. padding: 13,
  172. background: '#0a0a0a',
  173. borderRadius: 62,
  174. boxShadow: '0 0 0 2px #2a2a2a, 0 30px 80px rgba(0,0,0,0.35), 0 10px 30px rgba(0,0,0,0.2)',
  175. position: 'relative',
  176. transform: `scale(${scale})`,
  177. transformOrigin: 'center center',
  178. ...style,
  179. }}>
  180. <div style={{
  181. position: 'relative', width: W, height: H,
  182. borderRadius: 50, overflow: 'hidden', background: PAPER,
  183. }}>
  184. {/* Status bar */}
  185. <div style={{
  186. position: 'absolute', top: 0, left: 0, right: 0, height: 54,
  187. display: 'flex', alignItems: 'center', justifyContent: 'space-between',
  188. padding: '0 34px', fontSize: 17, fontWeight: 600,
  189. fontFamily: '-apple-system, "SF Pro Text", sans-serif',
  190. color: '#000', zIndex: 20, pointerEvents: 'none',
  191. }}>
  192. <span>{time}</span>
  193. <div style={{display:'flex', alignItems:'center', gap: 6}}>
  194. <div style={{display:'flex', alignItems:'flex-end', gap: 2, height: 12}}>
  195. {[4, 6, 9, 11].map((h, i) => <div key={i} style={{width:3, height:h, background:'#000', borderRadius:1}} />)}
  196. </div>
  197. <svg width="16" height="12" viewBox="0 0 16 12">
  198. <path d="M8 11.5a1 1 0 100-2 1 1 0 000 2z" fill="#000" />
  199. <path d="M3 7.5a7 7 0 0110 0" stroke="#000" strokeWidth="1.3" fill="none" strokeLinecap="round" />
  200. <path d="M1 4.5a11 11 0 0114 0" stroke="#000" strokeWidth="1.3" fill="none" strokeLinecap="round" opacity="0.7" />
  201. </svg>
  202. <div style={{width:26, height:12, border:'1.5px solid #000', borderRadius:3, padding:1, position:'relative'}}>
  203. <div style={{width:'85%', height:'100%', background:'#000', borderRadius:1}} />
  204. <div style={{position:'absolute', top:3, right:-3, width:2, height:6, background:'#000', borderRadius:'0 1px 1px 0'}} />
  205. </div>
  206. </div>
  207. </div>
  208. {/* Dynamic island */}
  209. <div style={{
  210. position: 'absolute', top: 12, left: '50%',
  211. transform: 'translateX(-50%)', width: 124, height: 36,
  212. background: '#000', borderRadius: 999, zIndex: 30,
  213. }} />
  214. {/* Content */}
  215. <div style={{position:'absolute', top: 54, left: 0, right: 0, bottom: 34, overflow: 'hidden'}}>
  216. {children}
  217. </div>
  218. {/* Home indicator */}
  219. <div style={{
  220. position: 'absolute', bottom: 10, left: '50%',
  221. transform: 'translateX(-50%)', width: 140, height: 5,
  222. background: 'rgba(0,0,0,0.28)', borderRadius: 999, zIndex: 10,
  223. }} />
  224. </div>
  225. </div>
  226. );
  227. }
  228. // ── Screen: Today ────────────────────────────────────────
  229. function TodayScreen({ animateT = 1 }) {
  230. const headerOp = interpolate(animateT, [0, 0.25], [0, 1]);
  231. const headerY = interpolate(animateT, [0, 0.35], [20, 0], Easing.easeOut);
  232. const heroOp = interpolate(animateT, [0.15, 0.5], [0, 1]);
  233. const heroY = interpolate(animateT, [0.15, 0.5], [30, 0], Easing.easeOut);
  234. const memoriesOp = interpolate(animateT, [0.4, 0.8], [0, 1]);
  235. return (
  236. <div style={{padding: '24px 22px 0', height: '100%', display:'flex', flexDirection:'column', background: PAPER}}>
  237. {/* Header */}
  238. <div style={{opacity: headerOp, transform: `translateY(${headerY}px)`, marginBottom: 18}}>
  239. <div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
  240. 周二 · 4月20日
  241. </div>
  242. <div style={{fontFamily: serif, fontSize: 34, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
  243. 今日
  244. </div>
  245. </div>
  246. {/* Hero card */}
  247. <div style={{opacity: heroOp, transform: `translateY(${heroY}px)`, border: `1px solid ${LINE}`, background:'#fff', marginBottom: 14}}>
  248. <ArtBlock mood="warm" height={180} />
  249. <div style={{padding: '14px 16px 16px'}}>
  250. <div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 6}}>
  251. 继续阅读 · 剩 12 分钟
  252. </div>
  253. <div style={{fontFamily: serif, fontSize: 20, fontWeight: 500, color: INK, lineHeight: 1.2, marginBottom: 4, letterSpacing:'-0.005em'}}>
  254. 《<span style={{fontStyle:'italic'}}>沉思录</span>》
  255. </div>
  256. <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13, color: ASH}}>
  257. 马可·奥勒留 · 第四卷
  258. </div>
  259. {/* AI insight */}
  260. <div style={{marginTop: 14, paddingTop: 12, borderTop: `1px solid ${LINE}`,
  261. display:'flex', gap: 10, alignItems: 'flex-start'}}>
  262. <div style={{width: 6, height: 6, borderRadius:'50%', background: TERRA, marginTop: 6, flexShrink: 0}} />
  263. <div>
  264. <div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 2}}>
  265. 流明 · 已关联
  266. </div>
  267. <div style={{fontFamily: serif, fontSize: 13, color: INK, lineHeight: 1.4}}>
  268. 呼应你 3 周前读的《<span style={{fontStyle:'italic'}}>塞涅卡书简·28</span>》——同在谈<span style={{fontStyle:'italic'}}>内心堡垒</span>。
  269. </div>
  270. </div>
  271. </div>
  272. </div>
  273. </div>
  274. {/* Memory bubbles list */}
  275. <div style={{opacity: memoriesOp, flex: 1}}>
  276. <div style={{fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.25em', marginBottom: 10}}>
  277. 来自你的记忆
  278. </div>
  279. {[
  280. { title: '"Amor fati"——一个说法', sub: '尼采 · 2 个月前', dot: OLIVE },
  281. { title: '论「注意力即爱」', sub: '薇依 · 5 个月前', dot: TERRA },
  282. ].map((m, i) => (
  283. <div key={i} style={{padding: '11px 0', borderTop: i === 0 ? `1px solid ${LINE}` : 'none', borderBottom: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 12}}>
  284. <div style={{width: 8, height: 8, borderRadius: '50%', background: m.dot}} />
  285. <div style={{flex: 1}}>
  286. <div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.3}}>{m.title}</div>
  287. <div style={{fontFamily: mono, fontSize: 9, color: ASH, marginTop: 2, letterSpacing:'0.1em'}}>{m.sub}</div>
  288. </div>
  289. <div style={{fontFamily: serif, fontSize: 18, color: ASH, fontStyle:'italic'}}>→</div>
  290. </div>
  291. ))}
  292. </div>
  293. </div>
  294. );
  295. }
  296. // ── Screen: Memory (graph view) ───────────────────────────
  297. function MemoryScreen({ animateT = 1 }) {
  298. const headerOp = interpolate(animateT, [0, 0.3], [0, 1]);
  299. const graphOp = interpolate(animateT, [0.15, 0.6], [0, 1]);
  300. const listOp = interpolate(animateT, [0.5, 0.9], [0, 1]);
  301. // Nodes for graph
  302. const nodes = [
  303. { x: 210, y: 100, r: 22, label: '斯多葛', emph: true },
  304. { x: 110, y: 180, r: 14, label: '伦理' },
  305. { x: 310, y: 170, r: 16, label: '美德', emph: true },
  306. { x: 90, y: 260, r: 10, label: '' },
  307. { x: 200, y: 240, r: 12, label: '' },
  308. { x: 320, y: 270, r: 18, label: '自我' },
  309. { x: 150, y: 330, r: 11, label: '' },
  310. { x: 280, y: 340, r: 13, label: '心流' },
  311. ];
  312. const edges = [
  313. [0, 1], [0, 2], [0, 4], [1, 3], [2, 5], [4, 5], [4, 6], [5, 7], [6, 7], [1, 4],
  314. ];
  315. return (
  316. <div style={{padding: '24px 22px 0', height: '100%', background: PAPER, display:'flex', flexDirection:'column'}}>
  317. <div style={{opacity: headerOp, marginBottom: 14}}>
  318. <div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
  319. 287 条 · 4 个聚类
  320. </div>
  321. <div style={{fontFamily: serif, fontSize: 34, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
  322. 记忆
  323. </div>
  324. </div>
  325. {/* Graph visualization */}
  326. <div style={{
  327. opacity: graphOp, border:`1px solid ${LINE}`, background:'#fff',
  328. height: 400, position:'relative', overflow:'hidden', marginBottom: 14,
  329. }}>
  330. <svg viewBox="0 0 420 400" width="100%" height="100%" style={{display:'block'}}>
  331. {/* edges */}
  332. {edges.map(([a, b], i) => {
  333. const na = nodes[a], nb = nodes[b];
  334. return <line key={i} x1={na.x} y1={na.y} x2={nb.x} y2={nb.y}
  335. stroke="#c8beb0" strokeWidth={0.8} opacity={0.7} />;
  336. })}
  337. {/* nodes */}
  338. {nodes.map((n, i) => {
  339. const appear = interpolate(animateT, [0.2 + i * 0.04, 0.4 + i * 0.04], [0, 1], Easing.easeOut);
  340. return (
  341. <g key={i} opacity={appear}>
  342. <circle cx={n.x} cy={n.y} r={n.r}
  343. fill={n.emph ? TERRA : '#ede5d3'}
  344. stroke={n.emph ? TERRA : '#b8ac94'}
  345. strokeWidth={1} />
  346. {n.label && (
  347. <text x={n.x} y={n.y + n.r + 14} textAnchor="middle"
  348. fontFamily={serif} fontStyle="italic" fontSize={11}
  349. fill={n.emph ? TERRA : '#666'}>
  350. {n.label}
  351. </text>
  352. )}
  353. </g>
  354. );
  355. })}
  356. </svg>
  357. {/* corner label */}
  358. <div style={{position:'absolute', top: 12, left: 14,
  359. fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.2em'}}>
  360. · 图谱视图
  361. </div>
  362. <div style={{position:'absolute', bottom: 12, right: 14,
  363. fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em'}}>
  364. 斯多葛派 · 47 条
  365. </div>
  366. </div>
  367. {/* Top clusters */}
  368. <div style={{opacity: listOp}}>
  369. <div style={{fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.25em', marginBottom: 8}}>
  370. 主要聚类
  371. </div>
  372. {[
  373. { name: '斯多葛', count: 47, swatch: TERRA },
  374. { name: '注意力', count: 32, swatch: OLIVE },
  375. ].map((c, i) => (
  376. <div key={i} style={{padding: '9px 0', borderTop: i === 0 ? `1px solid ${LINE}` : 'none', borderBottom: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 10}}>
  377. <div style={{width: 14, height: 14, background: c.swatch, borderRadius: 2}} />
  378. <div style={{flex: 1, fontFamily: serif, fontSize: 14, color: INK}}>{c.name}</div>
  379. <div style={{fontFamily: mono, fontSize: 10, color: ASH}}>{c.count}</div>
  380. </div>
  381. ))}
  382. </div>
  383. </div>
  384. );
  385. }
  386. // ── Screen: Chat ─────────────────────────────────────────
  387. function ChatScreen({ animateT = 1 }) {
  388. const headerOp = interpolate(animateT, [0, 0.2], [0, 1]);
  389. const msg1Op = interpolate(animateT, [0.15, 0.35], [0, 1]);
  390. const msg2Op = interpolate(animateT, [0.4, 0.65], [0, 1]);
  391. const ctxCardOp = interpolate(animateT, [0.55, 0.75], [0, 1]);
  392. const msg3Op = interpolate(animateT, [0.7, 0.92], [0, 1]);
  393. const inputOp = interpolate(animateT, [0.5, 0.8], [0, 1]);
  394. // Typewriter for AI reply (msg2)
  395. const aiText = '两处呼应——马可谈的「内心堡垒」和塞涅卡第 28 封信中的独处。';
  396. const charCount = Math.floor(interpolate(animateT, [0.4, 0.7], [0, aiText.length]));
  397. const typed = aiText.slice(0, charCount);
  398. return (
  399. <div style={{padding: '24px 22px 0', height: '100%', background: PAPER, display:'flex', flexDirection:'column'}}>
  400. <div style={{opacity: headerOp, marginBottom: 16}}>
  401. <div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
  402. 流明 · 已关联
  403. </div>
  404. <div style={{fontFamily: serif, fontSize: 30, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
  405. 问你的记忆
  406. </div>
  407. </div>
  408. <div style={{flex: 1, display:'flex', flexDirection:'column', gap: 12}}>
  409. {/* User msg */}
  410. <div style={{opacity: msg1Op, alignSelf:'flex-end', maxWidth: '85%',
  411. background:'#f0e8d5', padding: '10px 14px', borderRadius: '18px 18px 4px 18px'}}>
  412. <div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.4}}>
  413. 我最近关于<span style={{fontStyle:'italic'}}>「独处」</span>在想什么?
  414. </div>
  415. </div>
  416. {/* AI reply (typewriter) */}
  417. <div style={{opacity: msg2Op, alignSelf:'flex-start', maxWidth: '90%',
  418. paddingLeft: 14, borderLeft: `2px solid ${TERRA}`}}>
  419. <div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 4}}>
  420. 流明
  421. </div>
  422. <div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.45, minHeight: 60}}>
  423. {typed}
  424. {charCount < aiText.length && charCount > 0 && (
  425. <span style={{color: TERRA, marginLeft: 2}}>|</span>
  426. )}
  427. </div>
  428. </div>
  429. {/* Context card */}
  430. <div style={{opacity: ctxCardOp, alignSelf:'flex-start', maxWidth: '88%',
  431. background:'#fff', border: `1px solid ${LINE}`, padding: '10px 12px',
  432. marginLeft: 14, display:'flex', gap: 10, alignItems:'center'}}>
  433. <div style={{width: 40, height: 40, flexShrink: 0, overflow:'hidden'}}>
  434. <ArtBlock mood="study" height={40} />
  435. </div>
  436. <div style={{flex: 1}}>
  437. <div style={{fontFamily: serif, fontSize: 12, fontWeight: 500, color: INK, lineHeight: 1.2}}>
  438. 塞涅卡 · 第 28 封信
  439. </div>
  440. <div style={{fontFamily: mono, fontSize: 8, color: ASH, marginTop: 2, letterSpacing:'0.15em'}}>
  441. 3 周前阅读 · 4 分钟
  442. </div>
  443. </div>
  444. <div style={{fontFamily: serif, fontSize: 16, color: ASH, fontStyle:'italic'}}>↗</div>
  445. </div>
  446. {/* User follow-up */}
  447. <div style={{opacity: msg3Op, alignSelf:'flex-end', maxWidth: '70%',
  448. background:'#f0e8d5', padding: '10px 14px', borderRadius: '18px 18px 4px 18px'}}>
  449. <div style={{fontFamily: serif, fontSize: 14, color: INK}}>
  450. 给我看原文段落。
  451. </div>
  452. </div>
  453. </div>
  454. {/* Input bar */}
  455. <div style={{opacity: inputOp, padding: '10px 0 16px',
  456. borderTop: `1px solid ${LINE}`, marginTop: 12,
  457. display:'flex', alignItems:'center', gap: 10}}>
  458. <div style={{flex: 1, fontFamily: serif, fontStyle:'italic',
  459. fontSize: 13, color: ASH}}>
  460. 从你的阅读里问我任何事…
  461. </div>
  462. <div style={{width: 28, height: 28, background: TERRA, borderRadius: '50%',
  463. display:'flex', alignItems:'center', justifyContent:'center',
  464. color:'#fff', fontFamily: sans, fontSize: 16}}>↑</div>
  465. </div>
  466. </div>
  467. );
  468. }
  469. // ── Tab bar ───────────────────────────────────────────────
  470. function TabBar({ active = 'today', tapping = null }) {
  471. const tabs = [
  472. { id: 'today', label: '今日' },
  473. { id: 'memory', label: '记忆' },
  474. { id: 'chat', label: '对话' },
  475. ];
  476. return (
  477. <div style={{
  478. position: 'absolute', bottom: 0, left: 0, right: 0,
  479. height: 72, background: 'rgba(253,251,245,0.95)',
  480. backdropFilter: 'blur(12px)',
  481. borderTop: `1px solid ${LINE}`,
  482. display: 'flex', alignItems: 'center',
  483. fontFamily: serif,
  484. }}>
  485. {tabs.map((t) => {
  486. const isActive = active === t.id;
  487. const isTapping = tapping === t.id;
  488. return (
  489. <div key={t.id} style={{
  490. flex: 1, textAlign:'center', position:'relative',
  491. padding: '12px 0 18px',
  492. }}>
  493. {/* Ripple */}
  494. {isTapping !== null && isTapping > 0 && isTapping < 1 && (
  495. <div style={{
  496. position:'absolute', top:'50%', left:'50%',
  497. transform: `translate(-50%, -50%) scale(${1 + isTapping * 2})`,
  498. width: 44, height: 44, borderRadius:'50%',
  499. background: TERRA, opacity: 0.25 * (1 - isTapping),
  500. pointerEvents:'none',
  501. }} />
  502. )}
  503. <div style={{
  504. fontSize: 15, fontWeight: isActive ? 600 : 400,
  505. fontStyle: isActive ? 'normal' : 'italic',
  506. color: isActive ? TERRA : ASH,
  507. letterSpacing: '0.02em',
  508. }}>
  509. {t.label}
  510. </div>
  511. {isActive && (
  512. <div style={{
  513. position:'absolute', bottom: 8, left:'50%',
  514. transform: 'translateX(-50%)',
  515. width: 18, height: 2, background: TERRA,
  516. }} />
  517. )}
  518. </div>
  519. );
  520. })}
  521. </div>
  522. );
  523. }
  524. // ── Scene composition ─────────────────────────────────────
  525. // Timeline:
  526. // 0.0 – 1.8 iPhone fade+bounce in
  527. // 1.8 – 7.5 Today screen (fills in)
  528. // 7.5 – 8.5 Tap on Memory tab (ripple)
  529. // 8.5 – 13.5 Memory screen
  530. // 13.5 – 14.5 Tap on Chat tab
  531. // 14.5 – 19.5 Chat screen
  532. // 19.5 – 21.5 Pan: phone shrinks + capability labels appear
  533. // 21.5 – 24.0 Hold final frame with labels
  534. function App() {
  535. return (
  536. <Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
  537. <MainComposition />
  538. </Stage>
  539. );
  540. }
  541. function MainComposition() {
  542. const time = useTime();
  543. // Phone entrance
  544. const entranceT = Math.min(1, Math.max(0, time / 1.8));
  545. const phoneOp = interpolate(entranceT, [0, 0.5], [0, 1]);
  546. const phoneScale = interpolate(entranceT, [0, 1], [0.82, 0.88], Easing.spring);
  547. // Pan-out in final scene (19.5 – 21.5)
  548. const panT = Math.min(1, Math.max(0, (time - 19.5) / 2));
  549. const finalScale = interpolate(panT, [0, 1], [0.88, 0.68], Easing.easeInOut);
  550. const finalX = interpolate(panT, [0, 1], [0, -200], Easing.easeInOut);
  551. const currentScale = panT > 0 ? finalScale : phoneScale;
  552. const currentX = panT > 0 ? finalX : 0;
  553. // Screen determination
  554. let activeScreen = 'today';
  555. let tapping = null; // { id: 'memory', t: 0..1 }
  556. let screenAnimateT = 1;
  557. let transitionProgress = 0;
  558. if (time < 7.5) {
  559. activeScreen = 'today';
  560. screenAnimateT = Math.min(1, Math.max(0, (time - 1.8) / 2.5));
  561. } else if (time < 8.5) {
  562. activeScreen = 'today';
  563. tapping = { id: 'memory', t: (time - 7.5) / 1.0 };
  564. transitionProgress = (time - 8.0) / 0.5; // slide starts at 8.0
  565. } else if (time < 13.5) {
  566. activeScreen = 'memory';
  567. screenAnimateT = Math.min(1, Math.max(0, (time - 8.5) / 2.5));
  568. } else if (time < 14.5) {
  569. activeScreen = 'memory';
  570. tapping = { id: 'chat', t: (time - 13.5) / 1.0 };
  571. transitionProgress = (time - 14.0) / 0.5;
  572. } else if (time < 19.5) {
  573. activeScreen = 'chat';
  574. screenAnimateT = Math.min(1, Math.max(0, (time - 14.5) / 2.5));
  575. } else {
  576. activeScreen = 'chat';
  577. screenAnimateT = 1;
  578. }
  579. return (
  580. <div style={{position:'absolute', inset:0, background: CREAM}}>
  581. {/* Phone */}
  582. <div style={{
  583. position: 'absolute', top: '50%', left: '50%',
  584. transform: `translate(calc(-50% + ${currentX}px), -50%) scale(${currentScale})`,
  585. opacity: phoneOp, transformOrigin: 'center center',
  586. }}>
  587. <IosFrame>
  588. <div style={{position:'relative', width:'100%', height:'100%'}}>
  589. {activeScreen === 'today' && <TodayScreen animateT={screenAnimateT} />}
  590. {activeScreen === 'memory' && <MemoryScreen animateT={screenAnimateT} />}
  591. {activeScreen === 'chat' && <ChatScreen animateT={screenAnimateT} />}
  592. <TabBar active={activeScreen} tapping={tapping ? tapping.t : null} />
  593. </div>
  594. </IosFrame>
  595. </div>
  596. {/* Capability labels (appear during pan-out 19.5+) */}
  597. {panT > 0.05 && <CapabilityLabels t={panT} />}
  598. {/* Masthead (tucked corner, always visible from ~2s) */}
  599. {time > 2 && time < 19.5 && (
  600. <div style={{
  601. position: 'absolute', top: 60, left: 80,
  602. opacity: Math.min(1, (time - 2) / 0.6),
  603. maxWidth: 420,
  604. }}>
  605. <div style={{fontFamily: mono, fontSize: 12, color: TERRA, letterSpacing:'0.3em', marginBottom: 10}}>
  606. iOS APP 原型
  607. </div>
  608. <div style={{fontFamily: serif, fontSize: 70, fontWeight: 500, color: INK, lineHeight: 1.05, letterSpacing:'-0.015em'}}>
  609. 真机。<br/>
  610. <span style={{fontStyle:'italic', color: TERRA}}>真</span>交互。
  611. </div>
  612. <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20, color: ASH, marginTop: 22, lineHeight: 1.55}}>
  613. iPhone 15 Pro 机身 · 灵动岛 · 状态驱动多屏<br/>
  614. AI 密度信息 · CSS 艺术 · Playwright 点击测试
  615. </div>
  616. </div>
  617. )}
  618. {/* Screen label (bottom) */}
  619. {time > 2 && time < 19.5 && (
  620. <ScreenLabel active={activeScreen} time={time} />
  621. )}
  622. {/* Watermark */}
  623. <div style={{position:'absolute', bottom: 24, right: 32,
  624. fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
  625. fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
  626. Created by Huashu-Design
  627. </div>
  628. </div>
  629. );
  630. }
  631. function ScreenLabel({ active, time }) {
  632. const label = { today: '屏幕 1 · 今日', memory: '屏幕 2 · 记忆', chat: '屏幕 3 · 对话' }[active];
  633. const idx = { today: 1, memory: 2, chat: 3 }[active];
  634. return (
  635. <div style={{
  636. position: 'absolute', bottom: 80, left: 80,
  637. fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.25em',
  638. opacity: 0.9,
  639. }}>
  640. <span style={{color: TERRA, marginRight: 12}}>0{idx}</span>
  641. <span>{label.toUpperCase()}</span>
  642. </div>
  643. );
  644. }
  645. function CapabilityLabels({ t }) {
  646. const labels = [
  647. { text: '真图 · Wikimedia / Met / Unsplash', y: 220, delay: 0.0 },
  648. { text: 'Inline React · 双击就开', y: 380, delay: 0.15 },
  649. { text: 'AppPhone · 状态驱动多屏切换', y: 540, delay: 0.30 },
  650. { text: '信息密度型 · 每屏 ≥ 3 处差异化', y: 700, delay: 0.45 },
  651. { text: 'Playwright · 交付前点击测试', y: 860, delay: 0.60 },
  652. ];
  653. return (
  654. <>
  655. {labels.map((l, i) => {
  656. const localT = Math.max(0, Math.min(1, (t - l.delay) / 0.35));
  657. const op = localT;
  658. const x = interpolate(localT, [0, 1], [1400, 1280], Easing.easeOut);
  659. return (
  660. <div key={i} style={{
  661. position: 'absolute', left: x, top: l.y,
  662. opacity: op, display:'flex', alignItems:'center', gap: 14,
  663. }}>
  664. <div style={{width: 60, height: 1, background: TERRA}} />
  665. <div>
  666. <div style={{fontFamily: mono, fontSize: 10, color: TERRA, letterSpacing:'0.25em', marginBottom: 3}}>
  667. 0{i + 1}
  668. </div>
  669. <div style={{fontFamily: serif, fontSize: 20, color: INK, lineHeight: 1.25, letterSpacing:'-0.005em'}}>
  670. {l.text}
  671. </div>
  672. </div>
  673. </div>
  674. );
  675. })}
  676. </>
  677. );
  678. }
  679. ReactDOM.createRoot(document.getElementById('root')).render(<App />);
  680. </script>
  681. </body>
  682. </html>