/* ============================================================
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。 */}
{WELCOME_CONTOURS.map((d, i) => (
))}
{/* Soft ROOY-tinted glow up top — 跟 closing__before 同款氛围渲染 */}
{/* Logo + slogan — center column. */}
让滑雪更简单
每一次滑行,都是一场为你定制的运动体验
{/* 按钮区 —— 三层 affordance 分清。 */}
onNav && onNav('onboarding-basicinfo')}>
用微信继续
onNav && onNav('onboarding-basicinfo')} style={{
width: '100%', padding: '14px 0',
background: R.systemFill, color: R.ink,
border: 'none',
borderRadius: B.card, cursor: 'pointer',
...T.headline, fontWeight: 500,
marginTop: 10,
}}>用手机号继续
onNav && onNav('home')} style={{
width: '100%', padding: '14px 0',
background: 'transparent', color: R.ink3, border: 'none',
cursor: 'pointer', ...T.subhead,
marginTop: 4,
}}>不登录,先看看 ›
);
}
/* 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')}>稍后 }
/>
{/* 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 (
onChange(o.id)} style={{
padding: '7px 18px', borderRadius: 999,
background: on ? R.ink : R.systemFill,
color: on ? '#fff' : R.ink2,
border: 'none',
cursor: 'pointer',
...T.subhead, fontWeight: on ? 600 : 400,
minHeight: 36,
transition: 'background .15s, color .15s',
}}>{o.l}
);
})}
);
}
/* 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 (
onChange(o.id)} style={{
padding: '10px 18px', borderRadius: 999,
background: on ? R.ink : R.systemFill,
color: on ? '#fff' : R.ink,
border: 'none', cursor: 'pointer',
...T.subhead, fontWeight: on ? 600 : 400,
minHeight: 38,
transition: 'background .15s, color .15s',
}}>{o.l}
);
})}
);
}
/* 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 (
onChange(o.id)} style={{
padding: '6px 14px', borderRadius: 999,
background: on ? R.ink : R.systemFill,
color: on ? '#fff' : R.ink,
border: 'none', cursor: 'pointer',
...T.footnote, fontWeight: on ? 600 : 400,
minHeight: 30,
transition: 'background .15s, color .15s',
}}>{o.l}
);
})}
);
}
/* 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 (
);
}
/* 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')}>现在配对
onNav && onNav('home')} style={{
width: '100%', padding: '12px 0',
background: 'transparent', color: R.ink3, border: 'none',
cursor: 'pointer', ...T.subhead, marginTop: 2,
}}>等到了再说
);
}
/* ── shared local primitives ────────────────────────────── */
function PickRow({ on, onClick, label, desc }) {
return (
{on ? (
) : (
)}
);
}
/* 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 (
);
}
function SkipLink({ onClick, children }) {
return (
{children}
);
}
/* 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,
});