/* ============================================================ ROOY · Onboarding 五步 + 共享 form 组件 ─────────────────────────────────────────────────────────── 需求:新用户画像采集 + 配对入口。完成后进 Home / Ski。 OnboardingWelcome — 大字 wordmark + 三按钮(微信 / 手机号 / 先看看) OnboardingBasicInfo — 7 字段,三组 InsetGroup(身份 / 滑行 / 体型) italic anchor = 昵称(personality moment) OnboardingGoal — 滑行目标 4 单选 OnboardingLevel — 滑雪水平 4 单选 OnboardingPair — 现在配对 / 等到了再说 Skip 路径: · Welcome "先看看" → home(出 flow) · BasicInfo / Goal / Level NavBar 右上"稍后" → home(出 flow) · Pair "等到了再说" → home(出 flow) accent budget:≤ 1 per screen。Welcome 不画图、不放 illustration —— 靠 typography 大字 + 全屏极淡 topo wallpaper 传达"这是雪场工具"。 本文件也是几个跨屏 form 组件的家:FieldRow / FieldValue / NameInput / GenderPills / ChipRow / ChipsRow / PickerSheet / WheelPicker 等 —— edit-profile.jsx 也消费这些。 ============================================================ */ /* welcome contour styles — injected once. */ if (typeof document !== 'undefined' && !document.getElementById('rooy-welcome-contour-style')) { const s = document.createElement('style'); s.id = 'rooy-welcome-contour-style'; s.textContent = ` .welcome-contours { -webkit-mask-image: radial-gradient(115% 70% at 50% 30%, #000 38%, transparent 92%); mask-image: radial-gradient(115% 70% at 50% 30%, #000 38%, transparent 92%); } /* Each contour starts invisible (dashoffset = pathLength). When the group toggles to is-visible we animate dashoffset back to 0, drawing the stroke from start to end. Per-path inline transitionDelay cascades the lines from outer (low elevation) to inner (summit). */ .welcome-contours path { stroke-dasharray: 1; stroke-dashoffset: 1; } .welcome-contours.is-visible path { stroke-dashoffset: 0; transition: stroke-dashoffset 2400ms cubic-bezier(0.45, 0.05, 0.2, 1); } `; document.head.appendChild(s); } /* Topographic contours — real marching-squares level sets, not hand-drawn. ──────────────────────────────────────────────────────────────────────── 构造一个 400×900 的 height field h(x,y):3 座各向异性高斯峰 + 2 个低频 固定相位 sin/cos 扰动 (≈±0.14 的大尺度起伏)。然后在 96×216 网格上离散 采样,对 3 个等高线 level (0.30..0.88) 跑标准 marching squares (saddle 用 cell-center value 消歧),再把分散的线段按端点 hash 拼成折线。 结果是"真正的等高线"——同一峰嵌套但每条形状不同、山脊处向下凹、 山谷处向上凹、密度随坡度变化——和真实 topo map 同源。 主峰故意放在 (200, 258):屏上 1/3,logo 上方,等高线形成框选效果。 仅保留两个低频 octave,砍掉高频扰动——高频扰动让相邻等高线非平行、 出现局部"咯噔",违反真实地形"邻线大致平行"的视觉规律。 */ const WELCOME_CONTOURS = (() => { const W = 400, H = 900, cols = 96, rows = 216; const dx = W / cols, dy = H / rows, stride = cols + 1; /* 峰位对角分布在屏两角,避开 logo 中心区域 (200, 440)。 要让等高线"看起来是等高线",必须能看到嵌套+平行的同心结构——这是 地形图的视觉语义。但若主峰在屏中央,嵌套环会"框住 logo"成装饰画 框。对角放峰:屏左上 + 屏右下各一座山,嵌套结构清晰可读,logo 区 是两峰之间的鞍部留白。 */ const peaks = [ { cx: 50, cy: 70, sx: 130, sy: 140, a: 1.00 }, // 屏左上嵌套 { cx: 240, cy: 680, sx: 120, sy: 105, a: 0.95 }, // 屏下偏中:近圆形嵌套(3 圈可读),左端延伸到屏左边 ]; const h = (x, y) => { let v = 0; for (const p of peaks) { const ax = (x - p.cx) / p.sx, ay = (y - p.cy) / p.sy; v += p.a * Math.exp(-0.5 * (ax * ax + ay * ay)); } // 2 个低频 octave —— 制造大尺度不对称但不破坏"邻线大致平行"。 v += 0.090 * Math.sin(x * 0.0145 + y * 0.0195 + 1.31); v += 0.050 * Math.cos(x * 0.0285 - y * 0.0235 + 4.22); return v; }; const field = new Float32Array(stride * (rows + 1)); for (let j = 0; j <= rows; j++) for (let i = 0; i <= cols; i++) field[j * stride + i] = h(i * dx, j * dy); // Marching squares on one elevation level → array of line segments. const levelSegments = (level) => { const segs = []; for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { const tl = field[j * stride + i]; const tr = field[j * stride + i + 1]; const br = field[(j + 1) * stride + i + 1]; const bl = field[(j + 1) * stride + i]; let code = 0; if (tl >= level) code |= 1; if (tr >= level) code |= 2; if (br >= level) code |= 4; if (bl >= level) code |= 8; if (code === 0 || code === 15) continue; const x0 = i * dx, y0 = j * dy, x1 = x0 + dx, y1 = y0 + dy; const lerp = (va, vb, ax, ay, bx, by) => { const t = (level - va) / (vb - va); return [ax + t * (bx - ax), ay + t * (by - ay)]; }; const eT = () => lerp(tl, tr, x0, y0, x1, y0); const eR = () => lerp(tr, br, x1, y0, x1, y1); const eB = () => lerp(bl, br, x0, y1, x1, y1); const eL = () => lerp(tl, bl, x0, y0, x0, y1); const center = (tl + tr + br + bl) * 0.25; let pairs; switch (code) { case 1: pairs = [[eL(), eT()]]; break; case 2: pairs = [[eT(), eR()]]; break; case 3: pairs = [[eL(), eR()]]; break; case 4: pairs = [[eR(), eB()]]; break; /* saddle — center >= level: high corners (tl,br) connected through cell center, so contour wraps low corners (tr,bl). */ case 5: pairs = (center >= level) ? [[eT(), eR()], [eL(), eB()]] : [[eL(), eT()], [eR(), eB()]]; break; case 6: pairs = [[eT(), eB()]]; break; case 7: pairs = [[eL(), eB()]]; break; case 8: pairs = [[eB(), eL()]]; break; case 9: pairs = [[eB(), eT()]]; break; case 10: pairs = (center >= level) ? [[eL(), eT()], [eR(), eB()]] : [[eT(), eR()], [eL(), eB()]]; break; case 11: pairs = [[eB(), eR()]]; break; case 12: pairs = [[eR(), eL()]]; break; case 13: pairs = [[eR(), eT()]]; break; case 14: pairs = [[eT(), eL()]]; break; } for (const [a, b] of pairs) segs.push([a[0], a[1], b[0], b[1]]); } } return segs; }; // Stitch raw segments end-to-end into continuous polylines by endpoint hash. const stitch = (segs) => { const k = (x, y) => `${Math.round(x * 10)}_${Math.round(y * 10)}`; const map = new Map(); segs.forEach((s, idx) => { const k1 = k(s[0], s[1]), k2 = k(s[2], s[3]); if (!map.has(k1)) map.set(k1, []); if (!map.has(k2)) map.set(k2, []); map.get(k1).push({ idx, side: 0 }); map.get(k2).push({ idx, side: 1 }); }); const used = new Array(segs.length).fill(false); const walk = (startIdx, fromSide) => { const pts = []; let cur = startIdx, side = fromSide; while (cur !== -1 && !used[cur]) { used[cur] = true; const s = segs[cur]; const [ax, ay, bx, by] = side === 0 ? [s[0], s[1], s[2], s[3]] : [s[2], s[3], s[0], s[1]]; if (pts.length === 0) pts.push([ax, ay]); pts.push([bx, by]); const cand = map.get(k(bx, by)) || []; let next = -1, nextSide = 0; for (const c of cand) { if (c.idx !== cur && !used[c.idx]) { next = c.idx; nextSide = c.side; break; } } cur = next; side = nextSide; } return pts; }; const polys = []; for (let i = 0; i < segs.length; i++) { if (used[i]) continue; const fwd = walk(i, 0); const head = fwd[0]; const cand = map.get(k(head[0], head[1])) || []; let backIdx = -1, backSide = 0; for (const c of cand) { if (!used[c.idx]) { backIdx = c.idx; backSide = c.side; break; } } let poly = fwd; if (backIdx !== -1) { const back = walk(backIdx, backSide); back.reverse(); poly = [...back.slice(0, -1), ...fwd]; } polys.push(poly); } return polys; }; /* Catmull-Rom-to-cubic-Bézier — turns a polyline of grid-cell crossings into a smooth curve that still passes through every point. For each segment Pi → Pi+1, the cubic's control points are derived from the neighbouring tangents (Pi+1 - Pi-1)/6 and (Pi+2 - Pi)/6. Result: real- map smoothness instead of marching-squares polygonal steps. Handles open polylines (endpoints clamped) and closed loops (wrap-around). */ const toPath = (p) => { if (p.length < 2) return ''; if (p.length < 3) { return `M${p[0][0].toFixed(1)},${p[0][1].toFixed(1)}L${p[1][0].toFixed(1)},${p[1][1].toFixed(1)}`; } const closed = Math.abs(p[0][0] - p[p.length - 1][0]) < 0.5 && Math.abs(p[0][1] - p[p.length - 1][1]) < 0.5; const pts = closed ? p.slice(0, -1) : p; const n = pts.length; const get = (i) => closed ? pts[((i % n) + n) % n] : pts[Math.max(0, Math.min(n - 1, i))]; const segs = closed ? n : n - 1; let s = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`; for (let i = 0; i < segs; i++) { const p0 = get(i - 1), p1 = get(i), p2 = get(i + 1), p3 = get(i + 2); const c1x = p1[0] + (p2[0] - p0[0]) / 6; const c1y = p1[1] + (p2[1] - p0[1]) / 6; const c2x = p2[0] - (p3[0] - p1[0]) / 6; const c2y = p2[1] - (p3[1] - p1[1]) / 6; s += `C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`; } if (closed) s += 'Z'; return s; }; /* 3 levels picked so the resulting ring radii are roughly evenly spaced — for a Gaussian peak, r ∝ √(−ln level), so equal r-gaps need geometrically spread level values, not linearly spread. 比上版稍微外推/内缩——把环间距 拉宽一档;最外圈仍安全高于扰动天花板 (≈±0.14),不会被噪声切碎。 */ const levels = [0.30, 0.58, 0.88]; /* Length filter: drop polylines shorter than 40 vertices — those are noise specks (a single perturbation crest poking above the level inside one or two grid cells), not real contours. 阈值随网格密度同步放大 (96/64 ≈ 1.5x)。*/ const paths = []; for (const lv of levels) { for (const p of stitch(levelSegments(lv))) { if (p.length >= 40) paths.push(toPath(p)); } } return paths; })(); function OnboardingWelcome({ onNav }) { const [visible, setVisible] = React.useState(false); React.useEffect(() => { const t = setTimeout(() => setVisible(true), 120); return () => clearTimeout(t); }, []); return (
{/* Topo contour wallpaper — marching-squares level sets (see WELCOME_CONTOURS above). ROOY 色 stroke @ 36% alpha + radial mask 边缘 fade,opacity 0→1 在 mount 后 220ms 缓出 1.4s。 */} {/* Soft ROOY-tinted glow up top — 跟 closing__before 同款氛围渲染 */}
{/* Logo + slogan — center column. */}
ROOY

让滑雪更简单

每一次滑行,都是一场为你定制的运动体验

{/* 按钮区 —— 三层 affordance 分清。 */}
onNav && onNav('onboarding-basicinfo')}> 用微信继续
); } /* WeChatGlyph —— 极简对话气泡,不模仿微信品牌色(保持中性) */ function WeChatGlyph() { return ( ); } function OnboardingBasicInfo({ onNav }) { const [name, setName] = React.useState('Yuki'); const [gender, setGender] = React.useState('f'); const [age, setAge] = React.useState(29); const [height, setHeight] = React.useState(168); const [weight, setWeight] = React.useState(54); const [openPicker, setOpenPicker] = React.useState(null); return (
onNav && onNav('onboarding-welcome')} title="" right={ onNav && onNav('home')}>稍后} />

聊聊你

几条基础信息,ROOY 用来给你定起点。

{/* HERO · 昵称 ── italic anchor 让"取名 = 身份"成为第一屏 personality moment. 其他字段都是客观数据, 用普通行/chip 表达, 名字 ONLY 享受 italic 大字 + 下划线 input 待遇. ⚠ 标签 "你叫" 用 body 17 ink (匹配下方 FieldRow label 字号), 不再是 eyebrow 13. */}
你叫
setName(e.target.value)} maxLength={20} style={{ ...N.italic, fontSize: 56, color: R.ink, lineHeight: 1, letterSpacing: '-0.02em', background: 'transparent', border: 'none', outline: 'none', padding: 0, flex: 1, minWidth: 0, fontWeight: 900, }} />

后面 ROOY 都这样喊你。

{/* 4 editorial fields — full-bleed FieldRow list */}
setOpenPicker('age')}> setOpenPicker('height')}> setOpenPicker('weight')}>
onNav && onNav('onboarding-goal')}>下一步
{/* Picker bottom sheet — iOS-wheel-style scroll-snap picker. 年龄/身高/体重 用同一个 sheet shell, content 切换. */} {openPicker && ( 10 + i) // 10-90 岁 : openPicker === 'height' ? Array.from({ length: 81 }, (_, i) => 140 + i) // 140-220 cm : Array.from({ length: 91 }, (_, i) => 35 + i) // 35-125 kg } unit={ openPicker === 'age' ? '岁' : openPicker === 'height' ? 'cm' : 'kg' } onChange={(v) => { if (openPicker === 'age') setAge(v); else if (openPicker === 'height') setHeight(v); else setWeight(v); }} onClose={() => setOpenPicker(null)} /> )}
); } /* NameInput — value slot for 昵称 row. Right-aligned text input styled identically to FieldValue (italic 22, ink, weight 800, tight tracking) so the row reads as a coherent label+value pair even though it's a live text field. */ function NameInput({ value, onChange }) { return ( onChange(e.target.value)} maxLength={20} style={{ ...N.italic, fontSize: 22, color: R.ink, lineHeight: 1, fontWeight: 800, letterSpacing: '-0.02em', background: 'transparent', border: 'none', outline: 'none', padding: 0, textAlign: 'right', width: 140, }} /> ); } /* GenderPills — value slot for 性别 row. iOS systemFill 灰底未选, ink 填充选中 — 跟下方 ChipRow / iOS segmented 一致, 不再用 outline pill. Extracted so 11 + 15a share the exact pill styling. */ function GenderPills({ value, onChange }) { return (
{[ { id: 'f', l: '女' }, { id: 'm', l: '男' }, ].map(o => { const on = value === o.id; return ( ); })}
); } /* ChipRow — single-select chip group. iOS systemFill bg by default, selected = ink fill. flex-wrap for ≥4 options. */ function ChipRow({ value, onChange, options }) { return (
{options.map(o => { const on = value === o.id; return ( ); })}
); } /* ChipsRow — settings-style row whose right side is a small chip group instead of a single value. Used for low-cardinality picks (性别) that belong with other "objective attributes" in the same InsetGroup. */ function ChipsRow({ l, value, onChange, options }) { return (
{l}
{options.map(o => { const on = value === o.id; return ( ); })}
); } /* PickerRow — settings-style row that opens a wheel picker. Tap target = entire row. Right side: big tabular value + small unit + chevron. */ function PickerRow({ l, v, u, onClick }) { return (
{l}
{v} {u && {u}}
); } /* PickerSheet — iOS-style bottom sheet wrapping a WheelPicker. Scrim dim + drag handle + title row + wheel below. 完成 按钮去掉:drag handle 下滑 / 点击 scrim 都已经能关,"完成" 是 chrome 冗余。 */ function PickerSheet({ title, value, options, unit, onChange, onClose }) { return (
e.stopPropagation()} style={{ width: '100%', background: R.bg, borderTopLeftRadius: B.sheet, borderTopRightRadius: B.sheet, padding: '10px 0 24px', boxShadow: Shadow.sheet, }}>
{title}
); } /* WheelPicker — iOS-style vertical scroll picker. CSS scroll-snap-y handles snap; onScroll updates selection from scrollTop. - ITEM_H 40 (each row height) - VISIBLE 5 rows (200px total) - center band: 1.5px hairline top/bottom (iOS picker tray) - selected: N.hero 28, ink, weight 800 - others: N.reg 20, ink3, fading toward edges */ function WheelPicker({ value, onChange, options, unit }) { const ref = React.useRef(null); const ITEM_H = 40; const VISIBLE = 5; const center = Math.floor(VISIBLE / 2); // Position scroll at the current value on mount / value change React.useEffect(() => { if (!ref.current) return; const idx = options.indexOf(value); if (idx >= 0) ref.current.scrollTop = idx * ITEM_H; }, []); const handleScroll = () => { if (!ref.current) return; const idx = Math.round(ref.current.scrollTop / ITEM_H); const next = options[Math.max(0, Math.min(options.length - 1, idx))]; if (next !== value) onChange(next); }; return (
{/* iOS picker tray — hairline above and below the center row */}
{options.map((o) => { const selected = o === value; return (
{o} {unit && ( {unit} )}
); })}
); } function OnboardingGoal({ onNav }) { const [selected, setSelected] = React.useState('hobby'); const goals = [ { id: 'start', label: '刚刚入门', desc: '想顺利滑下蓝道' }, { id: 'hobby', label: '休闲爱好', desc: '想滑得更舒服' }, { id: 'skill', label: '技术提升', desc: '想把动作练到位' }, { id: 'race', label: '备赛训练', desc: '为比赛做准备' }, ]; return (
onNav && onNav('onboarding-basicinfo')} title="" right={ onNav && onNav('home')}>稍后} />

你的目标?

选一个最贴的,之后随时能改。

{goals.map(g => ( setSelected(g.id)} label={g.label} desc={g.desc}/> ))}
onNav && onNav('onboarding-level')}>下一步
); } function OnboardingLevel({ onNav }) { const [selected, setSelected] = React.useState('mid'); const levels = [ { id: 'beginner', label: '初学', desc: '第一次穿雪板' }, { id: 'novice', label: '入门', desc: '能滑下蓝道' }, { id: 'mid', label: '中阶', desc: '红道流畅' }, { id: 'adv', label: '高阶', desc: '黑道能下' }, ]; return (
onNav && onNav('onboarding-goal')} title="" right={ onNav && onNav('home')}>稍后} />

你的水平?

凭感觉选就好,滑两次 ROOY 会校准。

{levels.map(lv => ( setSelected(lv.id)} label={lv.label} desc={lv.desc}/> ))}
onNav && onNav('onboarding-pair')}>下一步
); } function OnboardingPair({ onNav }) { return (
onNav && onNav('onboarding-level')} title=""/>

传感器要连吗?

两枚卡在双板靴外侧搭扣上,开机后跟手机配对一次就行。

{/* 直接复用配对页的 SensorPair(idle 态)—— 不再画靴子图。 两枚 disc 是用户后面会反复见到的硬件视觉锚点;让它在 onboarding 就提前认人。caption 说明位置,文字代替插图。 */}
{typeof SensorPair === 'function' && }

左右各一枚 · 蓝牙连接

onNav && onNav('pairing-searching')}>现在配对
); } /* ── shared local primitives ────────────────────────────── */ function PickRow({ on, onClick, label, desc }) { return ( ); } /* FieldRow — onboarding editorial form row. Full-bleed on paper bg, hairline top (except first), tap-target = entire row. - label: body 17pt ink (iOS Settings list row standard) - value slot: arbitrary content right (italic number, chips, etc) - chevron only when onClick present (picker affordance) */ function FieldRow({ l, children, onClick, first }) { return (
{l}
{children} {onClick && }
); } /* FieldValue — italic anchor for editorial onboarding rows. Mid-size italic number + small unit subscript, matching the "你叫 Yuki" italic vibe in the hero. */ function FieldValue({ v, u }) { return (
{v} {u && {u}}
); } function InfoRow({ l, r, placeholder = '未填', onClick }) { return (
{l}
{r || placeholder}
); } function SkipLink({ onClick, children }) { return ( ); } /* StepProgress —— onboarding 底部 4 段细 progress。 每段 4pt 高、6pt 间距;已完成段 ink 实心,当前段 ROOY 色,未来段 hair。视觉负担轻,但用户能感知"快完了"。 */ /* StepProgress — page-indicator 风格:current 段拉长 + 其它短点。 不全宽(不抢镜),同时颜色对比 ink/rooy/hair2 加强易读性。 */ function StepProgress({ total = 4, step = 1 }) { return (
{Array.from({ length: total }).map((_, i) => { const done = i < step - 1; const cur = i === step - 1; const bg = done ? R.ink : cur ? R.rooy : R.hair2; const w = cur ? 32 : 8; return (
); })}
); } Object.assign(window, { OnboardingWelcome, OnboardingBasicInfo, OnboardingGoal, OnboardingLevel, OnboardingPair, PickRow, InfoRow, SkipLink, StepProgress, ChipRow, ChipsRow, PickerRow, PickerSheet, WheelPicker, FieldRow, FieldValue, NameInput, GenderPills, WeChatGlyph, });