/* ============================================================ ROOY · 16 / 17 / 18 配对三态 ─────────────────────────────────────────────────────────── 需求:配对是 TASK 不是 destination —— bottom sheet 从 source 屏 (设置 / pre-session / onboarding)弹出。 状态完全由两枚传感器 disc 自身样貌承载(L/R 标识 = AirPods 引用), **不放进度条 / 步骤条**: idle — hair2 ring + ink3 字 + 浅填色 searching — ink ring + ink 字 + 内部三段同心雷达波扩散 found — ink ring + R.data 蓝字("我找到你了") connected — R.ink 实心 + 反白字 + 下方 ROOY 绿光点 Accent budget: searching 0 · found 1 (R.data) · connected 1 (R.rooy)。 配对成功"绑定 ROOY"的瞬间是这屏唯一 ROOY 绿出现的点。 单 `` 组件驱动三态;3 个 artboard 渲染同样的 sheet 配不同 state,让 modal 架构可见。Sheet header drag handle 承担下滑关闭,不放显式"完成"按钮。 ============================================================ */ function PairingSearching({ onNav, embedded }) { return ; } function PairingFound({ onNav, embedded }) { return ; } function PairingConnected({ onNav, embedded }) { return ; } /* PairingSheetScreen — full-phone artboard wrapper: light settings backdrop + scrim + PairingSheet. Each artboard fixes its initial state so we have discrete demos in the canvas. `embedded` (prototype): skip the StatusBar / settings peek / scrim and render PairingSheet so it fills the parent — the prototype router supplies a real source screen underneath + its own backdrop + slide-up transition. */ function PairingSheetScreen({ state, onNav, embedded }) { const [showDisconnect, setShowDisconnect] = React.useState(false); const disconnectSheet = showDisconnect && ( setShowDisconnect(false) }]} onCancel={() => setShowDisconnect(false)} /> ); if (embedded) { return ( <> onNav && onNav('pairing-connected')} onStart={() => onNav && onNav('presession')} onDisconnect={() => setShowDisconnect(true)} /> {disconnectSheet} ); } return ( <>
{/* Settings-page backdrop peek — gives context that the sheet sits on top of settings (the natural source). Pointer-events off so the sheet captures all input. */} {/* Scrim */}
onNav && onNav('pairing-connected')} onStart={() => onNav && onNav('presession')} onDisconnect={() => setShowDisconnect(true)} />
{disconnectSheet} ); } /* SettingsBackdrop — a light approximation of the settings screen behind the sheet. We don't render the full SettingsScreen because it would compute heavy and look distracting under a scrim. Just header peek: title + profile row. */ function SettingsBackdrop() { return (

Yuki

高阶滑手 滑雪发烧友

); } /* PairingSheet — bottom-sheet shell with state-driven content. In self-contained mode (Design Canvas) sheet height is fixed at 600 so layout doesn't shift between states. In `embedded` mode (prototype router) the sheet fills its parent, since the parent is already sized to the sheet's footprint. */ function PairingSheet({ state, onConnect, onStart, onDisconnect, embedded }) { return (
{/* Drag handle */}
{/* Title row — drag handle 已经承担"下滑关闭"信号, 不再放 "完成"按钮 (chrome 冗余). 标题居中, "传感器" 跨三态保持稳定; state-specific copy (正在搜索 / 已发现 / 已连接) 仍在 disc 下方, sheet header 不跳变. */}

传感器

{state === 'searching' && } {state === 'found' && } {state === 'connected' && }
); } /* searching body — animating discs + status + help affordance (5s delayed). 稍后 link removed: drag handle 下滑关闭已覆盖 exit. */ function PairingSearchingBody() { const [showFail, setShowFail] = React.useState(false); React.useEffect(() => { const t = setTimeout(() => setShowFail(true), 5000); return () => clearTimeout(t); }, []); return (

正在搜索

两枚都开机,放在手机旁边。

还没找到?

长按传感器侧面 3 秒重启。

); } /* found body — discs in found state with pop-in animation + L/R info rows + 连接 CTA. State label moves to 已发现 (cleaner than "都找到了"). */ function PairingFoundBody({ onConnect }) { return (

已发现

连接
); } /* connected body — discs in connected state + L/R rows + 全部断开 (small inline link, less destructive-shouty) + 开始滑行 primary CTA. Firmware moved off main view — users don't need it every visit. */ function PairingConnectedBody({ onStart, onDisconnect }) { return (

已连接

开始滑行
); } /* QuestionGlyph — proper ? icon SVG instead of text "?" character. Used in the "没找到" help card. */ function QuestionGlyph({ size = 14, color = '#0E1116' }) { return ( ); } /* FirmwareRow — info row inside the connected InsetGroup. Lighter typography than ConnRow (footnote ink3) since firmware isn't actionable. */ function FirmwareRow({ version }) { return (
固件 {version}
); } /* PairRow — L/R sensor row in the found/connected InsetGroup. `tone`: 'found' → L/R letter in R.data blue (discovery); default → R.ink (already connected). Replaces the old ConnRow/FoundRow pair. */ function PairRow({ side, name, rssi, battery, tone }) { const letterColor = tone === 'found' ? R.data : R.ink; return (
{side} {name} {rssi} · {battery}%
); } /* SensorPair — 两枚 disc 并排,左 L 右 R。disc 内字母即左右指示。 */ function SensorPair({ state = 'idle', leftState, rightState, size = 124 }) { const lState = leftState || state; const rState = rightState || state; return (
); } /* Sensor — disc + L/R glyph + 状态相关的内部细节。 核心视觉:扁平实心圆 + 字母 + 状态色。无角标贴纸。 仅 connected 多一个 R.rooy 绿点(字母正下方,呼吸氛围灯感)。 */ function Sensor({ state = 'idle', size = 124, label = 'L' }) { const isIdle = state === 'idle'; const isSearching = state === 'searching'; const isFound = state === 'found'; const isConnected = state === 'connected'; const ringColor = isIdle ? R.hair2 : R.ink; const fillColor = isConnected ? R.ink : 'rgba(14,17,22,0.025)'; const letterColor = isConnected ? '#fff' : isFound ? R.data : isIdle ? R.ink3 : R.ink; const letterSize = Math.round(size * 0.42); const greenDot = Math.max(5, Math.round(size * 0.055)); const greenDotBottom = Math.round(size * 0.18); const innerRingInset = Math.round(size * 0.09); return (
{isSearching && ( {[0, 0.6, 1.2].map((delay, i) => ( ))} )} {label} {isConnected && ( )}
); } Object.assign(window, { PairingSearching, PairingFound, PairingConnected, PairingSheet, PairingSheetScreen, Sensor, SensorPair, PairRow, FirmwareRow, QuestionGlyph, }); /* pair-found-in 弹入 keyframe —— 注入一次 */ if (typeof document !== 'undefined' && !document.getElementById('rooy-pair-found-style')) { const s = document.createElement('style'); s.id = 'rooy-pair-found-style'; s.textContent = ` @keyframes rooyPairFoundIn { 0% { opacity: 0; transform: scale(0.88) translateY(6px); } 100% { opacity: 1; transform: scale(1) translateY(0); } } .pair-found-in { animation: rooyPairFoundIn .52s cubic-bezier(.22,1.2,.36,1) both; } /* connected 绿点呼吸 —— 配对成功唯一的彩色时刻 + 动效焦点。 opacity + scale 双呼吸;transform 保留 translateX(-50%) 居中, scale 同时放大 box-shadow 辉光(免费"收张"),无需动 shadow。 */ @keyframes rooyPairPulse { 0%, 100% { opacity: .82; transform: translateX(-50%) scale(1); } 50% { opacity: 1; transform: translateX(-50%) scale(1.14); } } .rooy-pair-pulse { animation: rooyPairPulse 2.4s ease-in-out infinite; } @media (prefers-reduced-motion: reduce) { .pair-found-in { animation: none; } .rooy-pair-pulse { animation: none; } } `; document.head.appendChild(s); }