/* ============================================================ ROOY · 04 对话 + 04a 镜头切换 + 输入相关组件 ─────────────────────────────────────────────────────────── 04 需求:用户与 ROOY 教练的全部交互空间,也是系统通知统一入口 (成就 / Session 总结 / 课程推荐 / 缆车检测都以 ROOY 第一人称 发出)。 消息节奏: - 跨 group 16px 呼吸,group 内 4px 紧凑 - DayMarker = thin hairline + centered date pill(上 18 / 下 14) - ROOY group 顶部 kicker timestamp 跟气泡左缘对齐 - Session summary embed 卡是独立全宽白卡,不假装气泡 - LiveCard 是"刚刚" notification 节拍(白卡 + hairline outline) 气泡视觉: - ROOY (RBub) = R.systemFill 灰底 + B.bubble (18/18/18/6 tail) - 用户 (UBub) = R.ink 黑底白字 + B.bubbleU (18/18/6/18 tail) - **聊天页不用 ROOY 绿染气泡** —— tail 形状已经做方向语义, 色彩做"轻 vs 重"语义就够(教练听得轻、用户说得重) quick reply chips:白卡 + hairline outline(vs 灰气泡),跟 LiveCard 同语言(白卡 = 可操作浮空层 / 灰 = 对话内容)。 04a LensSheet 需求:从对话流筛出成长档案(按时间 / 课程过滤 + 关键词搜索)。底部弹窗形态,drag handle 下滑关闭,**不放显式 "取消"按钮**(chrome 冗余)。 ============================================================ */ function RooyScreen({ onNav }) { const [draft, setDraft] = React.useState(''); const [actionOpen, setActionOpen] = React.useState(false); const [voiceMode, setVoiceMode] = React.useState(false); return (
{/* ── Header — restful, iMessage-grade ────── */}
{/* iOS chevron 返回 —— 触发栈弹出 / tab 回退(同滑动手势). chevron 比 iOS 标 (12×21) 稍大,跟 38pt avatar 视觉重心更平衡. */}
onNav && onNav('rooy-profile')} style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', }}>
R

ROOY

教学中

{/* header 右侧组:升级 Pro 入口 + 历程回顾。平行四边形 pill(skew) 实色 rooy + 白字,运动感;靠右放。 */}
{/* "历程回顾" entry — clock face (semantic carried by the icon being a clock, label disambiguates direction). */}
{/* ── Messages scroll ─────────────────────── */}
昨晚下了 12cm 新雪。先去 Furi 那条蓝道试感觉,不急着上 Stockhorn。 立刃首次稳定破 40°,连续三趟都保持得很好。 感觉了!但右弯还是有点飘。 对,我看到了。

观察

右弯入弯重心慢 0.15 s

今天试一件事:右弯入弯时,想象把脚趾往雪里 踩一下
好。试试。 onNav && onNav('ski-c')}/>
{/* ── Quick replies ────────────────────────── iOS SiriKit / iMessage Suggestion 风格:systemFill 灰底 pill,无 shadow、无 outline。不再用 R.card + Shadow.card——白卡 + drop shadow 在 .bg 上读起来"重量过头",像浮空 LiveCard 而不是输入辅助 条。systemFill 灰底跟 ROOY 灰气泡同色但形状/位置不同(pill 在 input 上方一条,气泡是消息流内),不会语义混淆。 */}
{['整体怎么样?', '明天练什么', '继续上次的课'].map((q, i) => ( ))}
{/* ── Input bar ────────────────────────────── */} setActionOpen(true)} onVoiceStart={() => setVoiceMode(true)} onVoiceEnd={() => setVoiceMode(false)} onSend={() => setDraft('')} /> {/* Voice overlay — visible while user holds the + button past the long-press threshold. Centered modal-ish over the chat. */} {voiceMode && } {/* Action sheet — bottom sheet with 拍照 / 录像 / 相册 options. */} {actionOpen && setActionOpen(false)}/>}
); } /* ChatInput — iOS chat input with state-driven right button. Empty draft: "+" button. · TAP → onActionTap (opens action sheet) · LONG-PRESS (≥ 320ms) → onVoiceStart, hold = recording. Release → onVoiceEnd. (Long-press fires BEFORE tap intent is decided; on release we check which fired.) Non-empty draft: "+" swaps to filled 发送 button (single tap). */ function ChatInput({ value, onChange, onActionTap, onVoiceStart, onVoiceEnd, onSend }) { const hasText = !!value.trim(); const pressTimer = React.useRef(null); const longPressFired = React.useRef(false); const [recording, setRecording] = React.useState(false); /* Long-press 走 pointer events,tap (action sheet) 走 onClick。 原因:startPress 之前调 e.preventDefault() 阻止默认手势, 副作用是 Android Chrome 不再合成 click —— pointerup 之后什么 都没发生,action sheet 永远打不开。改成 onClick 主导 tap, pointer events 只测长按 timer,最稳。touchAction:'none' 仍 防双击放大。 */ const startPress = () => { if (hasText) return; longPressFired.current = false; pressTimer.current = setTimeout(() => { longPressFired.current = true; setRecording(true); onVoiceStart && onVoiceStart(); }, 320); }; const endPress = () => { if (hasText) return; clearTimeout(pressTimer.current); if (longPressFired.current) { setRecording(false); onVoiceEnd && onVoiceEnd(); } }; const cancelPress = () => { clearTimeout(pressTimer.current); if (longPressFired.current) { setRecording(false); onVoiceEnd && onVoiceEnd(); } }; const handleTap = () => { if (hasText) return; /* onClick fires after pointerup; if long-press fired, swallow the tap. Reset flag so the next tap isn't suppressed. */ if (longPressFired.current) { longPressFired.current = false; return; } onActionTap && onActionTap(); }; return (
{/* Input pill — 48pt 高 + 0.5px hairline。input lineHeight=46 占 满垂直空间, baseline 自然居中. */}
onChange(e.target.value)} placeholder="Ask ROOY…" style={{ ...T.body, color: R.ink, flex: 1, background: 'transparent', border: 'none', outline: 'none', padding: 0, minWidth: 0, lineHeight: '46px', }} />
{/* 右侧按钮 — 48×48 圆按钮,跟 input pill 同高,外置 Telegram / WeChat 模式(iMessage 是嵌入 pill 内的小 28pt,但 ROOY 的 + 要承担长按录音语义,独立大按钮更易触达)。 Send icon 用 iOS 标准上箭头 (▲),不是右箭头——发送是"往上发出 去"的语义,跟 iMessage / Messages / WhatsApp 一致. */} {hasText ? ( ) : ( )}
); } /* VoiceOverlay — full-card visual cue while holding to record. Sits over chat content but below input bar (so user can see the button they're holding). Mic glyph + animated waveform + hint. */ function VoiceOverlay() { return (

松开发送 · 上滑取消

); } /* WaveformBars — purely decorative animated bars to suggest input level. Pure CSS keyframes. */ function WaveformBars() { if (typeof document !== 'undefined' && !document.getElementById('rooy-wave-style')) { const s = document.createElement('style'); s.id = 'rooy-wave-style'; s.textContent = ` @keyframes rooyWave { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } } .rooy-wave-bar { width: 3px; height: 22px; background: #fff; border-radius: 1.5px; transform-origin: center; animation: rooyWave 0.9s ease-in-out infinite; } `; document.head.appendChild(s); } return (
{[0, 0.18, 0.06, 0.34, 0.12, 0.22, 0.04].map((d, i) => ( ))}
); } /* ChatActionSheet — iMessage 17-style inline tray. ════════════════════════════════════════════════════════════════ Renders as a full-width strip directly above the ChatInput bar, sharing the paper bg + sitting on a hairline divider — feels like the input area "extended upward" rather than a floating popover. Each option: 56pt circular systemFill disc + icon + caption below. Replaces the quick-replies row visually while open; tap outside to dismiss. Slide-up + fade entrance via injected keyframes (same pattern as WaveformBars). */ function ChatActionSheet({ onClose }) { if (typeof document !== 'undefined' && !document.getElementById('rooy-tray-style')) { const s = document.createElement('style'); s.id = 'rooy-tray-style'; s.textContent = ` @keyframes rooyTrayUp { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } `; document.head.appendChild(s); } const opts = [ { id: 'photo', l: '拍照', glyph: }, { id: 'video', l: '录像', glyph: }, { id: 'lib', l: '相册', glyph: }, ]; return (
e.stopPropagation()} style={{ position: 'absolute', left: 0, right: 0, /* Bottom = ChatInput 总高 (padding 8 + 48 input + 12 = 68) — tray 底边贴 input bar 顶,视觉上读作输入区"上扩". */ bottom: 68, background: R.bg, borderTop: `0.5px solid ${R.hair}`, padding: '20px 16px 22px', display: 'flex', justifyContent: 'space-around', animation: 'rooyTrayUp 0.22s cubic-bezier(0.32, 0.72, 0.18, 1)', }}> {opts.map((o) => ( ))}
); } function MicGlyph({ size = 20, color = '#fff' }) { return ( ); } function CameraGlyph({ size = 20, color }) { const c = color || 'currentColor'; return ( ); } function VideoGlyph({ size = 20, color }) { const c = color || 'currentColor'; return ( ); } function LibraryGlyph({ size = 20, color }) { const c = color || 'currentColor'; return ( ); } /* LessonInviteCard — ROOY's session-start message with an inline lesson-mode CTA. v2 corrections per review: · no Dot kicker (too "notification" feel for a chat bubble) · max-width 300 matches RBub (not full-width like LiveCard) · grey systemFill bg (green tint at 7% was too saturated for repeating chat use); CTA itself still carries ROOY 色 to anchor the lesson-mode signal One per session — restraint-compliant. */ function LessonInviteCard({ onStart }) { return (

今天先松一段试试感觉。想直接进上课,我陪你练 立刃 · 40°

{/* CTA 移到气泡右侧 — iOS chat-bubble inline-action 标准位置(消息内 的 action 永远右对齐,类似 iMessage 的 "Reply" suggestion)。 升一档到 md:T.subhead 600 + 10/18 padding,比原 sm(T.footnote 500 + 8/16)更有"主行动"分量,跟 ChatInput 48pt 同尺度生态对齐. */}
开始上课
); } /* ── Day marker — hairline + centered pill ───────────────── */ function DayMarker({ label: l }) { return (
{l}
); } /* ── MsgGroup — generous breath between speakers ─────────── */ function MsgGroup({ who, time, children }) { const isRooy = who === 'rooy'; return (
{time && (

{time}

)}
{children}
); } /* ROOY bubble — systemFill 灰底。embed 变体跟普通气泡用 leading "观察" eyebrow 区分,不靠绿色 tint —— 聊天页不染 ROOY 绿。 padding 9/14/10 跟 iMessage / WhatsApp 节奏对齐(17/26 T.para 已经 自带 lineHeight,padding 不需要太厚)。 maxWidth 312 ≈ 在 390pt iPhone 上占 ~80%、在 430pt Pro Max 上占 ~73%, 是 iMessage 标准上限——比 v1 的 282 大 30pt,长段落不再过早换行. */ function RBub({ children, embed }) { return (
{children}
); } /* User bubble — ink */ function UBub({ children }) { return (
{children}
); } /* LiveCard — "just now" notification beat(缆车上 / 自动消息)。 ════════════════════════════════════════════════════════════════ ⛔ DO NOT add a ROOY-green Dot / pulsing indicator to this card. ════════════════════════════════════════════════════════════════ 该屏的"ROOY 在场"信号已经由多处承担:kicker 文本 (ROOY 色) + ROOY-tail bubble 形状 + header avatar pip + "教学中" eyebrow。 再加一个 Dot 是绿色泛滥 —— kicker 已经是 lede,不需要复述。 多个 agent 已经反复犯过这个错,banner 长留。 */ function LiveCard({ text, kicker }) { return (

{kicker}

{text}

); } /* Session summary card —— 今日总结 editorial magazine 风。 italic anchor 仍然落在 chat 屏的 inline 0.15s;此卡 italic 是 editorial 装饰,3 个数字 + 标题 italic 让"今日总结"这个 moment 读起来像杂志封面。kicker 中文化。 */ function SessionSummaryCard({ headline, date, metrics, onOpen }) { return (

今日总结 · {date}

{headline}.

{metrics.map((m, i) => (

{m.l}

{m.v} {m.u && ( {m.u} )}

))}
); } /* ============================================================ RooyLensSheet — 04a 镜头切换 · 历程回顾 ─────────────────────────────────────────────────────────── 需求:从对话流筛出成长档案。底部弹窗形态。 过滤维度:时间(按月跳转)+ 课程 + 总结类型 + 关键词搜索。 每条记录:左侧 type 图标 + 标题/副信息 + 右侧日期 + chevron。 Sheet header:drag handle + 居中标题 "消息回顾",**不放显式 "取消"按钮**(drag handle 下滑关闭已经够,再加是 chrome 冗余)。 ============================================================ */ /* Single source of truth — each session has its place / km / duration / optional lesson topic / ROOY's post-session note. Season is the surface for cross-year navigation (see MonthJump). */ const LENS_ITEMS = [ { id: 1, season: '2025 — 26', m: 5, day: 3, place: 'Zermatt', km: 8.4, duration: '2:18 h', lesson: '立刃路径 · 第 3 节', summary: '右弯入弯快了 0.08s,比昨天稳。立刃连三趟稳在 40°。继续保持。' }, { id: 11, season: '2025 — 26', m: 4, day: 29, place: 'Verbier', km: 5.6, duration: '1:32 h', lesson: '立刃路径 · 第 2 节', summary: '7 趟里 5 趟节奏稳在 0.95s 上下。右弯立刃还有点迟疑,下次专门练这个。' }, { id: 4, season: '2025 — 26', m: 4, day: 23, place: 'Saas-Fee', km: 6.2, duration: '1:48 h', lesson: null, summary: '雪面偏硬,立刃角度普遍保守了 5°。这是对的,硬雪上太激进容易打滑。' }, { id: 5, season: '2025 — 26', m: 4, day: 18, place: 'Verbier', km: 9.0, duration: '2:04 h', lesson: '节奏路径 · 第 1 节', summary: '节奏路径起点不错,重心切换偏晚 0.12s。下次做一组节奏拍子练习。' }, { id: 12, season: '2025 — 26', m: 4, day: 12, place: 'Zermatt', km: 7.1, duration: '1:52 h', lesson: null, summary: '今天节奏稳定,无大问题。' }, { id: 13, season: '2025 — 26', m: 3, day: 27, place: 'Zermatt', km: 9.8, duration: '2:24 h', lesson: '立刃路径 · 第 1 节', summary: '立刃路径开课。基础数据:左弯 28°,右弯 25°。下次从右弯开始练。' }, { id: 8, season: '2025 — 26', m: 3, day: 8, place: 'Zermatt', km: 4.9, duration: '1:36 h', lesson: null, summary: '热身性质的滑行,状态稳定。' }, { id: 14, season: '2025 — 26', m: 2, day: 19, place: 'Saas-Fee', km: 7.8, duration: '2:08 h', lesson: null, summary: '雪季中段,节奏比 12 月稳定很多。' }, { id: 15, season: '2025 — 26', m: 1, day: 14, place: 'Zermatt', km: 5.5, duration: '1:42 h', lesson: null, summary: '今年第一次到 Zermatt,先适应雪场。' }, ]; const LENS_SEASONS = ['2025 — 26', '2024 — 25', '2023 — 24']; /* newest first */ const SEASON_MONTHS = [11, 12, 1, 2, 3, 4, 5]; /* a ski season runs Nov→May */ /* '2025 — 26' → [2025, 2026]. Nov / Dec belong to the first year, Jan – May to the second. */ function seasonYears(season) { const parts = season.split(/[—\-]/).map(s => s.trim()); const y1 = parseInt(parts[0], 10); const tail = parts[1] || ''; const y2 = tail.length === 2 ? parseInt(String(y1).slice(0, 2) + tail, 10) : parseInt(tail, 10); return [y1, y2]; } function yearFor(season, m) { const [y1, y2] = seasonYears(season); return (m === 11 || m === 12) ? y1 : y2; } const LENS_MONTHS = [11, 12, 1, 2, 3, 4, 5]; const LENS_MONTH_DAYS = { 1: 31, 2: 28, 3: 31, 4: 30, 5: 31, 6: 30, 7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31 }; /* Mock course list for the 课程 filter chip. range is a human-readable label; in production these come from the curriculum/progress store. */ const LENS_COURSES = [ { id: 'parallel', l: '平行式', range: '2024-12 — 2025-01' }, { id: 'carving', l: '刃滑路径', range: '2025-01 — 现在' }, { id: 'short', l: '短弯节奏', range: '2025-02 — 现在' }, ]; /* ============================================================ RooyLensSheet — 04a · 历程回顾 ─────────────────────────────────────────────────────────── Rebuilt against iOS HIG + ui-ux-pro-max checklist: - Scrim opacity 0.45 (HIG: 40–60% black for modal scrim) - Sheet corner radius 20 (iOS 17 sheet style) - 36×5pt drag handle (Apple system spec) - Header: 56pt, navigation-bar-style left-Done button + centered 17pt semibold title + trailing toggle - iOS Search Field: 36pt, systemFill background rgba(120,120,128,0.12), 15pt callout text, 13pt placeholder - Month strip: iOS Camera-style underline tabs, callout 16pt, active = bold + 2pt underline - Day cells: 44×44pt circular highlight, 17pt regular, record-dot 4pt under, weekday eyebrow above (HIG calendar) - List rows: 60pt minHeight, separator inset 56pt, SF-Symbol- style 22pt glyph leading, headline title + subhead meta, trailing date (monospaced) + 11pt chevron - Single "只看总结" toggle replaces 4-pill filter row, living in the trailing nav-bar slot (iOS Mail "Filter" UX) ============================================================ */ const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']; /* Anchor a calendar day-of-week mapping. 2025-11-01 was a Saturday; we treat the season as if it starts there so weekday rendering is deterministic across the strip without needing a real Date. */ const SEASON_ANCHOR = { m: 11, dayOfWeek: 6 /* Sat */ }; function weekdayFor(m, d) { /* months in season order: 11,12,1,2,3,4,5 — accumulate days */ const order = [11, 12, 1, 2, 3, 4, 5]; let offset = 0; for (const mm of order) { if (mm === m) break; offset += LENS_MONTH_DAYS[mm]; } return (SEASON_ANCHOR.dayOfWeek + offset + (d - 1)) % 7; } /* `embedded` prop: when true (used by prototype/app.jsx), the sheet body fills its parent container and StatusBar + the conversation peek + the self-rendered scrim are all skipped — the prototype's stack-based router supplies a real 04 RooyScreen underneath, and supplies its own backdrop + slide-up transition. When false (Design Canvas), the component stays self-contained so it can be previewed as a standalone 390×844 artboard. */ function RooyLensSheet({ onNav, embedded }) { const [season, setSeason] = React.useState(LENS_SEASONS[0]); const [showSummary, setShowSummary] = React.useState(false); const [keyword, setKeyword] = React.useState(''); const [jumpOpen, setJumpOpen] = React.useState(false); const [courseOpen, setCourseOpen] = React.useState(false); const [selectedCourse, setSelectedCourse] = React.useState(null); /* displayMonth — last-jumped month; powers the date chip's label. Defaults to the latest month with content. */ const [displayMonth, setDisplayMonth] = React.useState(null); const listRef = React.useRef(null); const sectionRefs = React.useRef({}); const grouped = React.useMemo(() => { const k = keyword.trim(); const matches = (it) => { if (it.season !== season) return false; if (!k) return true; return it.place.includes(k) || (it.lesson && it.lesson.includes(k)) || (showSummary && it.summary && it.summary.includes(k)); }; const list = LENS_ITEMS.filter(matches); const monthOrder = [5, 4, 3, 2, 1, 12, 11]; const out = []; for (const m of monthOrder) { const inMonth = list.filter(it => it.m === m); if (inMonth.length) out.push({ m, items: inMonth }); } return out; }, [season, keyword, showSummary]); const totalCount = grouped.reduce((acc, g) => acc + g.items.length, 0); const availableMonths = grouped.map(g => g.m); const jumpTo = (s, m) => { setJumpOpen(false); setDisplayMonth(m); if (s !== season) { /* picking a month in another season switches season first; the section will be in view once that re-render lands */ setSeason(s); return; } const el = sectionRefs.current[m]; if (!el || !listRef.current) return; listRef.current.scrollTo({ top: el.offsetTop - 4, behavior: 'smooth' }); }; /* derive the date-chip label: prefer user-picked month, else latest month with content, else fallback */ const dateLabel = (() => { const m = displayMonth ?? (grouped[0] && grouped[0].m); return m ? `${m} 月` : '时间'; })(); return (
{!embedded && <> {/* Conversation peek */}
R

ROOY

· 教学中

} {/* iOS sheet with HIG-conforming scrim — scrim is dropped in embedded mode because the prototype router puts a real backdrop in front of the live RooyScreen underneath. */}
{/* Drag handle */}
{/* iOS Nav bar: centered title only — drag handle 已经承担 "下滑关闭"信号, "取消"按钮 chrome 冗余, 已删除. Filter affordances 在下方 chip row. */}

消息回顾

{/* iOS Search field — clean, single-purpose. */}
setKeyword(e.target.value)} placeholder="搜索" style={{ ...T.subhead, color: R.ink, flex: 1, background: 'transparent', border: 'none', outline: 'none', padding: '0 4px', minWidth: 0, }} /> {keyword && ( )}
{/* Filter chip row — 时间 · 课程 · 显示总结. Each chip is its own affordance with active-state ink fill. Layout uses flex-wrap so popovers below aren't clipped by an overflow:auto container. */}
{ setJumpOpen(o => !o); setCourseOpen(false); }} label={dateLabel} seasons={LENS_SEASONS} season={season} onSeasonChange={setSeason} recordedByseason={LENS_ITEMS.reduce((acc, it) => { acc[it.season] = acc[it.season] || new Set(); acc[it.season].add(it.m); return acc; }, {})} onPick={jumpTo} /> { setCourseOpen(o => !o); setJumpOpen(false); }} courses={LENS_COURSES} selected={selectedCourse} onSelect={(c) => { setSelectedCourse(c); setCourseOpen(false); }} /> {/* 显示总结是 toggle(语义和前两个 dropdown 不同)。 variant="toggle" → 透明 outline 样式 + ROOY 绿 active, 跟 filter chip 视觉上拉开档次。marginLeft auto 推到行尾 做语义分组的视觉区分. */}
} label="显示总结" active={showSummary} onClick={() => setShowSummary(v => !v)} />
{/* List — cards, grouped by month with sticky headers */}
{totalCount === 0 ? (

没有匹配的记录

{keyword && ( )}
) : ( grouped.map((g) => (
{ sectionRefs.current[g.m] = el; }}>
{g.items.map((it) => ( ))}
)) )}
); } /* SectionHeader — iOS sticky group header with weight + scale. Month leads (17pt semibold ink, the thing the eye finds first), year trails baseline-aligned in 13pt mono ink3 — small enough to read as context, present enough to disambiguate across the season boundary (Nov 2025 vs Nov 2024). */ function SectionHeader({ year, month }) { return (
{month} 月 {year}
); } /* FilterChip — generic iOS-pill filter affordance. Two variants: - 'filter' (default): systemFill grey → ink fill on active. Pair with hasDropdown for chips that open a popover (iOS Mail filter bar 风格)。 - 'toggle': transparent + hairline outline → ink fill on active. Used for view options (e.g. 显示总结),inactive 时跟 filter 拉开 archive(outline vs filled),active 时统一进入 ink 主导态。 hasDropdown adds a small caret to suggest a popover follows. */ function FilterChip({ icon, label, active, onClick, hasDropdown, ariaLabel, variant = 'filter' }) { const isToggle = variant === 'toggle'; const bg = isToggle ? (active ? R.ink : 'transparent') : (active ? R.ink : 'rgba(118,118,128,0.14)'); const fg = active ? '#fff' : R.ink; const border = isToggle ? `0.5px solid ${active ? R.ink : R.hair2}` : 'none'; return ( ); } /* LensDateChip — date jump chip + popover. Triggers MonthJump-style popover anchored LEFT. The chip's label shows the current focused month (e.g. "5 月"); icon is permanent. */ function LensDateChip({ open, onOpen, label, seasons, season, onSeasonChange, recordedByseason, onPick }) { const popRef = React.useRef(null); React.useEffect(() => { if (!open) return; const onDoc = (e) => { if (popRef.current && !popRef.current.contains(e.target)) onOpen(); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [open, onOpen]); const cal = ( ); return (
{open && ( )}
); } /* LensCourseChip — course filter chip + popover. Popover lists mock LENS_COURSES with their date-range labels. selected: null | course object. */ function LensCourseChip({ open, onOpen, courses, selected, onSelect }) { const popRef = React.useRef(null); React.useEffect(() => { if (!open) return; const onDoc = (e) => { if (popRef.current && !popRef.current.contains(e.target)) onOpen(); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [open, onOpen]); const book = ( ); return (
{open && (
onSelect(null)} first/> {courses.map((c) => ( onSelect(c)}/> ))}
)}
); } function CourseRow({ l, range, picked, onClick, first }) { return ( ); } /* MonthJumpPanel — extracted popover content from MonthJump so it can be triggered by chip elsewhere. Anchor: 'left' | 'right'. */ function MonthJumpPanel({ anchor = 'right', seasons, season, onSeasonChange, recordedByseason, onPick }) { const [draftSeason, setDraftSeason] = React.useState(season); React.useEffect(() => { setDraftSeason(season); }, [season]); const idx = seasons.indexOf(draftSeason); const canPrev = idx < seasons.length - 1; const canNext = idx > 0; const recorded = (recordedByseason && recordedByseason[draftSeason]) || new Set(); return (
{draftSeason} 雪季
{LENS_MONTHS.map(m => { const has = recorded.has(m); return ( ); })}
); } /* LensDayCard — white rounded card. Layout mirrors the original Day row (Image #8 / #10): 56×56 date pill on the left (月 + day), place title at T.headline, single meta line uniformly separated by "|" — "8.4 km | 2:18 h | 立刃 路径 · 第 3 节" (inner "·" inside the lesson topic itself stays). When the global "显示总结" switch is on AND this session has a ROOY note, the card extends downward to reveal that note — hairline-separated, green ROOY dot + body. */ function LensDayCard({ item, showSummary, onClick }) { const expanded = showSummary && !!item.summary; const clickable = !!onClick; const Inner = clickable ? 'button' : 'div'; return (
{item.m} 月 {item.day}

{item.place}

{item.km} km {item.lesson && ( <> | {item.lesson} )}

{clickable && }
{expanded && (

{item.summary}

)}
); } Object.assign(window, { RooyScreen, RooyLensSheet, LensDayCard, FilterChip, LensDateChip, LensCourseChip, MonthJumpPanel, CourseRow, SectionHeader, ChatInput, ChatActionSheet, VoiceOverlay, LessonInviteCard, MicGlyph, CameraGlyph, VideoGlyph, LibraryGlyph, });