/* ============================================================ ROOY · 04b ROOY profile(彩蛋) ─────────────────────────────────────────────────────────── 需求(P1 / 支线):让 ROOY 有"真实社交媒体人物"的存在感。从 04 主对话头像进入。 结构(参照 IG / Threads profile): · Avatar + verified ✓ + 实时状态("在缆车上看你滑") · 3 stat 横排(一起滑过 / 看过 / 笔记) · Highlights rail(6 story-circle 玻璃化小封面) · Tabs:关于 / 教学笔记置顶 / 喜好 / 最近动态(默认动态) 交互:头像滚动折叠动画 —— 默认 tab 选了"动态"因为内容超一屏能 触发折叠 + nav pill 淡入;改默认会让交互演示不起来。 ============================================================ */ function RooyProfileScreen({ onNav }) { const [scrollTop, setScrollTop] = React.useState(0); const [liked, setLiked] = React.useState(false); /* Default tab = 动态:内容超一屏才能演示 hero 折叠 + pill 淡入。 改 default 会让交互演示失效。 */ const [activeTab, setActiveTab] = React.useState('resort'); const scrollRef = React.useRef(null); const rafRef = React.useRef(0); const snapTimerRef = React.useRef(null); /* Frame width — measured at runtime so hero collapse math (avatar centering, name center X) tracks the actual stage width. The stage now fills the viewport (no more 372 cap), so this can be 320 on SE up to 430 on Pro Max. Fallback 372 covers the first paint before ResizeObserver fires; the useLayoutEffect below overwrites it before the browser commits the first frame. */ const rootRef = React.useRef(null); const [FW, setFW] = React.useState(372); React.useLayoutEffect(() => { const el = rootRef.current; if (!el) return; const w0 = el.getBoundingClientRect().width; if (w0 > 0) setFW(w0); const ro = new ResizeObserver((entries) => { const w = entries[0].contentRect.width; if (w > 0) setFW(w); }); ro.observe(el); return () => ro.disconnect(); }, []); /* rAF-throttled scroll handler + snap-to-endpoint debounce. Snap 的作用是"别让用户长时间停在 hero 半折叠态"——半折叠的 avatar 尺寸 + name 半位移视觉上不稳,停下来看会一直觉得画面"在动"。 滚动停止 200ms 后,scrollTop ∈ (0, COLLAPSE) 则 smooth 滚到最近端点 (< COLLAPSE/2 回 0,否则去 COLLAPSE)。fully collapsed / fully expanded 时不动手。maxScroll < COLLAPSE 时不能去 COLLAPSE,只 snap 回 0. 200ms(原 150)—— 100~180ms 区间用户慢速浏览的指尖停顿会误触发, 200ms 几乎只在用户真正松手后才触发,不再"和手势打架"。 */ const handleScroll = (e) => { const target = e.currentTarget; if (rafRef.current) return; rafRef.current = requestAnimationFrame(() => { setScrollTop(target.scrollTop); rafRef.current = 0; }); if (snapTimerRef.current) clearTimeout(snapTimerRef.current); snapTimerRef.current = setTimeout(() => { const sc = scrollRef.current; if (!sc) return; const st = sc.scrollTop; if (st <= 0 || st >= COLLAPSE) return; const maxScroll = sc.scrollHeight - sc.clientHeight; const canReachCollapsed = maxScroll >= COLLAPSE; const dest = canReachCollapsed && st >= COLLAPSE / 2 ? COLLAPSE : 0; sc.scrollTo({ top: dest, behavior: 'smooth' }); }, 200); }; /* Tab switch — reset scroll so each tab starts at the top, hero expanded. Without this, switching from a long tab (notes) to short (about) leaves the hero stuck in collapsed state with empty space below. 同时 cancel 待执行的 snap timer,否则 snap 会在 tab 切完 200ms 后误触发. */ const onPickTab = (id) => { setActiveTab(id); if (snapTimerRef.current) clearTimeout(snapTimerRef.current); if (scrollRef.current) scrollRef.current.scrollTop = 0; }; /* Hero collapses 208 → 84pt over 140px of scroll. v5:从 220 → 140,让 04b 短 tab(喜好/动态)也能在自然 maxScroll 范围内完整折叠到 compact 状态。原 220 是为视觉舒展挑的——但实测 4 个 tab 都没法在那个距离内完整收掉。140 让 morph 快 35%,但 仍然是平滑曲线(easeInOutCubic + rAF 节流),不显仓促。 */ const COLLAPSE = 140; /* Dynamic bottom spacer — 原生 iOS 解法 (Apple Music / Photos / IG profile):当 tab 内容不足以让 scrollTop 达到 COLLAPSE 时,contentSize 兜底, 用透明 spacer 把 scrollHeight 补到至少 `clientHeight + COLLAPSE`, hero 不会"弹回"。 单帧 rAF 不够稳健 —— tab 切换后 layout 几帧才稳定 (font subset / svg reflow),第一次测量可能拿到瞬时态。多次 setTimeout (0/40/120/240ms) 覆盖整个 layout-stable 窗口,最终值锁定。Math.max(200, …) 兜底:哪怕 一次都没测准,spacer 至少 200pt 也保证短 tab 能滚到 collapse. */ const [spacerH, setSpacerH] = React.useState(280); React.useLayoutEffect(() => { const sc = scrollRef.current; if (!sc) return; const measure = () => { setSpacerH((cur) => { const containerH = sc.clientHeight; const contentH = sc.scrollHeight - cur; const need = Math.max(200, COLLAPSE + 48 - (contentH - containerH)); return Math.abs(need - cur) > 0.5 ? need : cur; }); }; const ids = [0, 40, 120, 240].map((d) => setTimeout(measure, d)); return () => ids.forEach(clearTimeout); }, [activeTab]); const tRaw = Math.min(1, scrollTop / COLLAPSE); const t = tRaw < 0.5 ? 4 * tRaw * tRaw * tRaw : 1 - Math.pow(-2 * tRaw + 2, 3) / 2; const lerp = (a, b) => a + (b - a) * t; /* 私信 pill — clean two-state crossfade replacing the v2 9-property morph. Pill fades in only after hero is mostly collapsed (tRaw 0.6 → 0.92) so it lands beside the compact avatar, not mid-transition. The inline 私信 button in the action row is a normal flex item — scrolls off naturally with the rest of the row. No shape interpolation, no intermediate "neither rect nor pill" state. 用 tRaw(normalized)而非 scrollTop,让阈值不依赖 COLLAPSE 绝对值。 */ const pillRaw = Math.max(0, Math.min(1, (tRaw - 0.6) / 0.32)); const pillEase = 1 - Math.pow(1 - pillRaw, 3); // easeOutCubic const pillOpacity = pillEase; const pillScale = 0.92 + 0.08 * pillEase; const pillTY = (1 - pillEase) * -6; /* v5:hero 整体上收。原 avTop 50pt + heroH 248 让 avatar 顶部距状态栏 有 100+pt 空白,太空。avTop → 28(接近 compact 24),整组 name/tagline 等同步上移 22pt;heroH 248 → 208 同步收 40pt,去掉 tagline 下面无用的 34pt empty space。 */ const heroH = lerp(208, 84); const avSize = lerp(92, 36); const avLeft = lerp((FW - 92) / 2, 14); const avTop = lerp(28, 24); const avLetter = lerp(40, 18); const nameSize = lerp(28, 17); const nameCenterX = lerp(FW / 2, 60); const nameAnchor = lerp(50, 0); /* Compact name: top 32. 状态合并进 name 行后变单行 ~20pt 高(17pt h1 lineHeight 1.15);avatar center cy = 24+18 = 42;single-line center 32+10 = 42,对齐 avatar 中心。 Expanded:avatar bottom = 28+92=120,gap 16 → name top 136. */ const nameTop = lerp(136, 32); /* Tagline — only thing left in the fading subtext block. Expanded 168 紧贴 name bottom (~136+32=168);compact position 不可见(subOpacity=0)。 */ const subOpacity = Math.max(0, 1 - tRaw * 2.2); const subTop = lerp(168, 130); /* Status "在缆车上" — 04a 风格:expanded 完全不渲染,折叠到 ~45% 后才 fade-in 出现在 ROOY 名右侧,inline `· 在缆车上`(和 rooy.jsx 04a 里 `· 教学中` 完全同结构 / 同 token)。 去掉了旧的顶部 chip(位置 / 背景 / 缩放变量都不再需要)。 */ const statusInlineOpacity = Math.min(1, Math.max(0, (tRaw - 0.45) / 0.45)); const coverOpacity = Math.max(0, 1 - tRaw * 1.3); return (
{/* 私信 pill — top-right, only visible after hero collapses. Crossfades with the inline 私信 button in the action row (which scrolls off naturally). Position is fixed to where the compact avatar lands at top:78, h:36 → vertical center 96, pill h:30 → top 81. No path morphing, no shape interpolation. */} {/* Scroll container */}
{/* ── Hero — sticky-collapsing header ────────────── */}
{/* Topographic banner — subtle ski-terrain vibe */}
{/* ROOY 色 radial glow — 弥散感衰减到 0.65,避开 PRODUCT.md anti-ref "冥想 app 弥散渐变 / fintech 渐变 hero 卡"。 改用 12%(原 18%)+ 64% radius(原 62%)让 glow 更收敛、 tone 更"晨光",主视觉 weight 留给 avatar 本身。 */}
{/* Bottom hairline — appears in compact state */}
{/* Avatar — interpolated. boxShadow 持续衰减(不再在 t=0.6 跳成 'none'):原 toggle 让 drop shadow 在中段突然消失 + 1.6px R.bg 环瞬间脱落,看起来 像"闪一下"。0.10→0 用 1-t 平滑衰减,halo ring 沿 lerp(4,0) 同步收到 0,整段折叠过程 0 discrete change. 权重对齐 Shadow.hero 量级(6/20)——原 14/32 /0.22 撞 Linear/shadcn anti-pattern, 近白 iOS 调性下 avatar 的分量靠尺寸承载、不靠重 drop. */}
R
{/* Name + verified + (compact-only) inline status. translateX 让 name 从居中 → 紧贴 avatar,无 textAlign 跳变。 Status `· 在缆车上` 用 position:absolute, left:100% 挂在 flex container 右侧外,**不参与 flex 宽度**——避免它在 tRaw=0.45 进/出 DOM 时让 flex width 跳变、再叠加 translateX(-N%) 锚点 位移,造成 ROOY + ✓ 整体瞬时水平跳一下(用户看到的"闪")。 opacity fade 仍由 statusInlineOpacity 控制. */}

ROOY

· 在缆车上
{/* Subtext — tagline only, centered always. Fades out by mid-scroll. */}

你的滑雪搭子

{/* ── Action row — 私信 + 喜欢. 紧接 hero 下方(stats 已移到数据 tab)。和下方 tabs 之间靠 HighlightsRail 的 marginTop 拉开。 行内 私信 自然滚出屏;顶部 pill (上面 absolute) 在 hero 折叠完成后淡入接替。 ──── */}
{/* ── Highlights rail — IG-style story circles 作为 tab 切换。 active 圈完整 ROOY ring + 文字 ink;inactive 降级 hairline 圈 + 文字 ink3。只显示 active tab 的 section,整屏缩短。 ──── */} {/* ── 当前 tab 的 section ──────────────────────── */} {activeTab === 'resort' && ( {RESORT_POSTS.map((p, i) => )} )} {activeTab === 'likes' && ( {QUIRKS.map((q, i) => )} )} {activeTab === 'notes' && ( {JOURNAL_NOTES.map((n, i) => ( ))} )} {activeTab === 'data' && ( {/* Stats card — 6 stat, 双行 3 列. 上行陪伴维度(一起 / 在线 / 笔记),下行教学维度(反馈 / 节课 / 雪场)。中间一条 hair 横向分组, divider prop 在每行的后两列加 0.5px 竖 hairline. */}
)}
); } /* ── ProfileStat — IG-style number-over-label cell. v4:stats 现在只在数据 tab 出现,作为 tab 的 hero block,所以默认值 回到 26pt / weight 800(最初版本就是这个)。`big` prop 保留为 future hook—如果以后再有"次级 stats"位置可以 fontSize 22。 */ function ProfileStat({ n, u, l, divider }) { return (
{n} {u && {u}}

{l}

); } /* ── HighlightsRail — IG Stories highlight pattern 作为 tab 切换 ─── 4 highlights ↔ 4 tabs. active 圈完整 ROOY conic ring;inactive 降级为 hairline 圈 + glyph 半透明 + label ink3。 颜色收敛:硬编码 hex 全部 token 化,4 个 bg 收敛到 ink / ROOY tint / systemFill 三档(关于=ink、动态=ROOY、其余=灰)。 */ /* tab 排列:动态首位(最日常 + 内容最长 = 默认)/ 喜好 / 教学笔 记 / 数据。4 个 disc 颜色统一到 systemFill 灰 —— active 状态完全 靠 ROOY conic ring 区分,不让任何 disc 在 inactive 时抢眼。 */ const HIGHLIGHTS = [ { l: '动态', target: 'resort', bg: 'color-mix(in srgb, var(--r-rooy) 14%, #fff)', fg: 'var(--r-rooy)', glyph: }, { l: '喜好', target: 'likes', bg: R.systemFill, fg: R.ink2, glyph: }, { l: '教学笔记', target: 'notes', bg: R.systemFill, fg: R.ink2, glyph: }, { l: '数据', target: 'data', bg: R.systemFill, fg: R.ink2, glyph: }, ]; function HighlightsRail({ active, onPick }) { return ( /* marginTop 32(原 26)— Stats 改扁平后,hero unit(avatar / name / status / stats / action row)整体上挪、上下都更紧凑;tab rail 需要额外 6pt breathing 拉开"人物信息" → "浏览选项"的章节断层。 */
{HIGHLIGHTS.map((it, i) => { const isActive = active === it.target; return ( ); })}
); } /* ── ProfileSection — layout wrapper only. v3:去掉 eyebrow title 渲染——tab label 已经声明了当前 section 名 ("关于" / "动态" / "喜好" / "教学笔记"),再在 section 顶部 echo 一次 eyebrow 是 redundant。title prop 保留作为可选 a11y 标记不影响视觉。 */ function ProfileSection({ title, children }) { return (
{children}
); } /* ── JournalEntry — 教学笔记日记条目 ────────────────────── 每条是独立的卡(白卡 + B.cardL 圆角 + 阴影),不再 hairline 叠 在一起,视觉像翻日记本一页页。卡内: · 顶部日期 header (eyebrow 13 ink3, 可选 pin 图标 + 周几) · 主 body T.para ink2 · 可选 italic tail 右下("(你睡了)") 不再有 borderLeft accent —— 保持纸感。 */ const JOURNAL_NOTES = [ { date: '5 月 3 日', weekday: '周五', time: '凌晨 4 点', pinned: true, body: (<>今早 Furi 蓝道,你右弯入弯还在犹豫 0.15s。我没说,因为你状态在调,自己会感觉到。

下午雪面化得快,注意立刃别太狠。), tail: '(你睡了)' }, { date: '5 月 2 日', weekday: '周四', time: '晚 22:14', body: (<>今天三趟稳定 40°,但只是第一组的事。第二组下来左右弯开始打回原形。

稳定不是靠"做",是靠"不忘"。明天我们试试节奏拍子。) }, { date: '4 月 30 日', weekday: '周一', time: '晚 21:08', body: '雪面偏硬,立刃普遍保守了 5°——这是对的。硬雪上太激进容易打滑,你今天判断比上周好。' } ]; function JournalEntry({ date, weekday, time, body, tail, pinned }) { return (
{date} {weekday && ( {weekday} )} {time && ( · {time} )}
{pinned && ( 置顶 )}

{body}

{tail && (

{tail}

)}
); } /* ── ResortPost — 一个雪场对应一条 ROOY 帖子,带定位 tag ───── 把 ROOY 在每个雪场的"故事 / 心情"按 social feed 形式展示, 而不是干巴巴的列表。每条都是 ROOY-voice 的小段子。 */ const RESORT_POSTS = [ { resort: 'Zermatt', when: '2 月 14 日 · 主场', body: '今天和你一起到了 Stockhorn 顶。3 532 m。第一脚踩下去那种感觉,每个人都该体验一次。', star: true, }, { resort: 'Verbier', when: '1 月 8 日', body: '4 Vallées 雪域。下午起风但雪面给力,跟着 Sergio 学的硬粉日立刃,今天才真正用上。', }, { resort: 'Saas-Fee', when: '7 月 22 日', body: '夏天的冰川。9 月还能开板 —— 瑞士给我的礼物。你想夏天滑吗?', }, { resort: 'Furi', when: '今天 · 训练点', body: 'Furi 蓝道,你刚开始的地方。每次回来看你滑,节奏都不太一样。', }, ]; function ResortPost({ resort, when, body, star }) { return (
R
ROOY {resort} {star && ( )}

{when}

{body}

); } /* ── QuirkCard — 喜好 row as a card with tinted accent disc ── */ /* QUIRKS 颜色收敛:5 disc 从 5 色 → 2 色二态。 规则:和 ROOY 心情 / 看法直接相关的(早班、硬粉数据)= ROOY tint; 中性观察 / 喜好声明(最爱山、缆车不说话、只教双板)= systemFill。 硬编码 hex (#B07A1F #EAECEF #F1ECE5 #7E6346) 全删;gold / data 外来 accent 也清掉。 */ const QUIRK_ROOY = { bg: 'color-mix(in srgb, var(--r-rooy) 14%, #fff)', fg: 'var(--r-rooy)' }; const QUIRK_GRAY = { bg: R.systemFill, fg: R.ink2 }; const QUIRKS = [ { glyph: , accent: QUIRK_ROOY.bg, accentFg: QUIRK_ROOY.fg, l: '早班的雪面最纯', d: '6 点开板那种。看你第一趟没人压过的雪,很有满足感。' }, { glyph: , accent: QUIRK_GRAY.bg, accentFg: QUIRK_GRAY.fg, l: '最爱 Stockhorn 顶', d: 'Zermatt 之外都还好。Stockhorn 顶第一脚踩下去那感觉无可替代。' }, { glyph: , accent: QUIRK_GRAY.bg, accentFg: QUIRK_GRAY.fg, l: '缆车上我不说话', d: '上山是给你休息的,不是给我教学的。除非你先开口。' }, { glyph: , accent: QUIRK_ROOY.bg, accentFg: QUIRK_ROOY.fg, l: '蓝天硬粉 > 暴风雪', d: '我知道粉雪日大家都嗨。但我看数据,硬粉里你滑得最稳。' }, { glyph: , accent: QUIRK_GRAY.bg, accentFg: QUIRK_GRAY.fg, l: '只教双板', d: '单板我也看得懂,但教不好。诚实点说。' }]; function QuirkCard({ glyph, accent, accentFg, l, d }) { return (
{glyph}

{l}

{d}

); } /* ── Glyphs ───────────────────────────────────────────────── */ /* Verified — burst-star silhouette, ROOY-color fill, white check. Smaller than IG/X badge but unmistakable as the same pattern. */ function VerifiedBadge({ size = 16 }) { return ( ); } function ChatGlyph({ size = 16 }) { return ( ); } function BellGlyph({ size = 18 }) { return ( ); } function HeartGlyph({ size = 16, filled }) { return ( ); } function StarGlyph({ size = 12 }) { return ( ); } function PinLocationGlyph({ size = 10 }) { return ( ); } function PinGlyph({ size = 11 }) { return ( ); } /* Quirk row glyphs (16×16) */ function SunriseGlyph() { return ( ); } function LiftGlyph() { return ( ); } function SnowGlyph() { return ( ); } function BoardGlyph() { return ( ); } function PeakGlyph() { return ( ); } /* Highlights story-circle glyphs — bigger, more graphic. */ function HLLetterR() { return ( R); } /* HLBio — 3 短横线 representing bio/about text. 替代之前的 HLLetterR, 避免 active 关于 tab 的"黑 R 圈"和 hero avatar 的"黑 R 圈"视觉重复。 3 行的最后一行短一段(像段落 ragged-right)让它读起来像"文本块"。 */ function HLBio() { return ( ); } function HLMountain() { return ( ); } function HLBook() { return ( ); } function HLHeart() { return ( ); } function HLPencil() { return ( ); } function HLChart() { return ( ); } Object.assign(window, { RooyProfileScreen, ProfileStat, ProfileSection, HighlightsRail, JournalEntry, QuirkCard, ResortPost, VerifiedBadge, ChatGlyph, BellGlyph, HeartGlyph, StarGlyph, PinGlyph, PinLocationGlyph, SunriseGlyph, LiftGlyph, SnowGlyph, BoardGlyph, PeakGlyph, HLLetterR, HLBio, HLMountain, HLBook, HLHeart, HLPencil, HLChart });