);
}
/* 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 (
);
}
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 (
);
}
/* ============================================================
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. */}
{/* 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. */}
{/* 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 (
);
}
/* 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}
>
)}