/* ============================================================ ROOY · Prototype shell ─────────────────────────────────────────────────────────── Wraps the existing v3 screens (all expose `onNav` already) in an iOS-style tab-bar router with per-tab navigation stacks: each of the three tabs (home / ski / rooy) keeps its own push stack, so switching tabs and coming back restores where you were (UITabBarController behavior). Exposed as `window.RooyNav.{go,back}` so screens that already call `onNav(target)` route through us. All three tab containers stay mounted at all times — switching tabs only flips a visibility flag, so screens don't re-mount and their push-in CSS animations don't replay on every tab swap. ============================================================ */ const { useState, useEffect, useRef, useCallback } = React; /* Screen registry — `comp` is a lazy getter so it resolves AFTER all screen modules have attached themselves to window. - `tab` which bottom-Tab the screen belongs to. - `kind` 'sheet' triggers bottom-sheet transition + embedded prop. - `group` screens with the same group transition with a fade replace instead of push (used for academy Segmented control: review ↔ course ↔ achievement). */ const SCREENS = { /* home flow */ 'home': { comp: () => window.HomeScreenQ, tab: 'home' }, 'home-firstrun': { comp: () => window.HomeScreenFirstRun, tab: 'home' }, 'settings': { comp: () => window.SettingsScreen, tab: 'home' }, 'settings-profile': { comp: () => window.EditProfileScreen, tab: 'home' }, 'season': { comp: () => window.SeasonScreenB, tab: 'home' }, 'history': { comp: () => window.HistoryScreen, tab: 'home' }, 'daily': { comp: () => window.DailyScreen, tab: 'home' }, 'review': { comp: () => window.ReviewScreenN, tab: 'home', group: 'academy' }, 'course': { comp: () => window.CourseScreen, tab: 'home', group: 'academy' }, 'course-detail': { comp: () => window.CourseDetailScreenB, tab: 'home' }, 'rooy-pro': { comp: () => window.RooyProScreen, tab: 'home' }, 'achievement': { comp: () => window.AchievementScreen, tab: 'home', group: 'academy' }, /* ski flow */ 'presession': { comp: () => window.PreSessionScreenC, tab: 'ski' }, 'presession-end': { comp: () => window.PreSessionScreenEnd, tab: 'ski' }, 'ski-b': { comp: () => window.SkiScreenB, tab: 'ski' }, 'ski-c': { comp: () => window.SkiScreenC, tab: 'ski' }, 'ski-d': { comp: () => window.SkiScreenD, tab: 'ski' }, /* rooy flow */ 'rooy': { comp: () => window.RooyScreen, tab: 'rooy' }, 'rooy-profile': { comp: () => window.RooyProfileScreen, tab: 'rooy' }, 'rooy-lens': { comp: () => window.RooyLensSheet, tab: 'rooy', kind: 'sheet' }, /* onboarding (no tab, full-screen takeover) */ 'onboarding-welcome': { comp: () => window.OnboardingWelcome }, 'onboarding-basicinfo': { comp: () => window.OnboardingBasicInfo }, 'onboarding-goal': { comp: () => window.OnboardingGoal }, 'onboarding-level': { comp: () => window.OnboardingLevel }, 'onboarding-pair': { comp: () => window.OnboardingPair }, /* pairing — bottom-sheet triad; embedded-prop contract. */ 'pairing-searching': { comp: () => window.PairingSearching, kind: 'sheet' }, 'pairing-found': { comp: () => window.PairingFound, kind: 'sheet' }, 'pairing-connected': { comp: () => window.PairingConnected, kind: 'sheet' }, }; const TAB_IDS = ['home', 'ski', 'rooy']; const TAB_ROOT = { home: 'home', ski: 'presession', rooy: 'rooy' }; const ANIM_MS = 280; /* `ski` is a special target id: from presession or presession-end it means "start / continue skiing" → push ski-b. Listing these here keeps the special-case branch readable. */ const SKI_START_FROM = new Set(['presession', 'presession-end']); /* First-run onboarding lives on its own stack outside the tab system — TabBar isn't visible during onboarding. Completing onboarding (any nav to home or presession) flips off the flag and drops the user on home tab with full state, not first-run. */ const ONBOARDING_IDS = new Set([ 'onboarding-welcome', 'onboarding-basicinfo', 'onboarding-goal', 'onboarding-level', 'onboarding-pair', ]); const ONBOARDING_EXIT = new Set(['home', 'presession']); function App() { const seqRef = useRef(1); /* `originTab` records which tab the user was on when this layer was pushed. Used by `back` to restore the caller's tab on pop when the layer was pushed via a cross-tab jump (e.g., 02a → 10 daily lives on home tab but back should return to ski tab). */ const mk = useCallback((id, anim, originTab) => { const meta = SCREENS[id]; const a = anim || 'push'; return { id, key: 's' + (seqRef.current++), kind: meta?.kind || 'screen', anim: a, originTab: originTab || null, /* `entering` drives the underlying layer's parallax push-out and the top's push-in/fade-in. A useEffect below clears it ANIM_MS after `createdAt` so a later pop doesn't replay push-in on this layer. `createdAt` is wall-clock time so the timer fires at the correct moment even if the stack re-renders mid-animation (e.g., the academy swap prune changing tabStacks at t=ANIM_MS would otherwise reset a deps-only timer and leak fade-in past its window). */ entering: a === 'push' || a === 'fade' || a === 'sheet', createdAt: Date.now(), }; }, []); const [activeTab, setActiveTab] = useState('home'); const [tabStacks, setTabStacks] = useState(() => ({ home: [{ id: 'home', key: 'h-root', kind: 'screen', anim: 'none' }], ski: [{ id: 'presession', key: 'k-root', kind: 'screen', anim: 'none' }], rooy: [{ id: 'rooy', key: 'r-root', kind: 'screen', anim: 'none' }], })); const [inOnboarding, setInOnboarding] = useState(true); const [onboardingStack, setOnboardingStack] = useState(() => [ { id: 'onboarding-welcome', key: 'ob-root', kind: 'screen', anim: 'none' }, ]); /* Two-phase back: mark top `leaving` so render attaches the pop-out animation, then prune from stack once it completes. If the popped layer was cross-tab-pushed (has originTab), restore the caller's tab so e.g. 02a → daily → back returns to 02a (on ski tab) rather than home tab root. Defined before `go` because go references it. */ /* When marking the top as leaving, also clear `entering` on the layer that's about to be revealed. That layer becomes the new top after the prune, and we don't want isTop && entering to replay push-in on it. */ const markTopLeaving = (arr) => arr.map((s, i) => { if (i === arr.length - 1 && !s.leaving) return { ...s, leaving: true }; if (i === arr.length - 2 && s.entering) return { ...s, entering: false }; return s; }); /* rooyEntryTabRef: only the ROOY chat page (rooy tab root) supports "swipe back → return to previous tab". We record the tab the user came from when they switch INTO rooy, and consume it on back from rooy root. Other tab roots don't have this — they're terminal. */ const rooyEntryTabRef = useRef(null); const back = useCallback(() => { if (inOnboarding) { const cur = onboardingStack; if (cur.length <= 1) return; const top = cur[cur.length - 1]; if (top.leaving) return; setOnboardingStack(prev => markTopLeaving(prev)); setTimeout(() => { setOnboardingStack(prev => prev.filter(s => !s.leaving)); }, ANIM_MS); return; } const cur = tabStacks[activeTab]; /* ROOY chat root only: back returns to the tab the user came from (fallback: home). Other tab roots have nothing to do on back — there's no logical "previous" for those. */ if (cur.length <= 1) { if (activeTab === 'rooy') { const prev = rooyEntryTabRef.current || 'home'; rooyEntryTabRef.current = null; if (prev !== 'rooy') setActiveTab(prev); } return; } const top = cur[cur.length - 1]; if (top.leaving) return; const restoreTab = top.originTab && top.originTab !== activeTab ? top.originTab : null; setTabStacks(prev => ({ ...prev, [activeTab]: markTopLeaving(prev[activeTab]), })); setTimeout(() => { setTabStacks(prev => ({ ...prev, [activeTab]: prev[activeTab].filter(s => !s.leaving), })); if (restoreTab) setActiveTab(restoreTab); }, ANIM_MS); }, [activeTab, tabStacks, inOnboarding, onboardingStack]); /* 50ms micro-window dedupe. React 18 dev mode double-invokes impure state updaters; SlideToStart's setProgress updater schedules `setTimeout(onStart, 280)` as a side effect, which gets registered twice and fires `go('ski')` twice in the same tick. 50ms is below any plausible human double-tap (≥ 100ms) but well above React's same-tick duplicate. */ const lastGoRef = useRef({ target: null, time: 0 }); /* Navigate. Resolution order: 1. `'back'` — explicit pop on current tab stack. 2. Tab id ('home' / 'ski' / 'rooy'): · 'ski' from presession / presession-end → push ski-b. · Different tab → switch active tab (per-tab stack kept). · Same tab → rewrite target to tab root, fall through. 3. Unknown screen id → warn and ignore. 4. Target lives in another tab → push to that tab + activate. 5. Same-group as current top → replace top (Segmented swap). 6. Target already in current stack → pop to that index (UINavigationController-style "go back to existing"). 7. Sheet kind → push with sheet transition. 8. Otherwise → regular push. */ const go = useCallback((target) => { const now = Date.now(); if (lastGoRef.current.target === target && now - lastGoRef.current.time < 50) { return; } lastGoRef.current = { target, time: now }; if (target === 'back') { back(); return; } /* Onboarding takes over the screen until the user reaches a real-app destination. Internal pushes / pop-to-existing happen on `onboardingStack`; pairing sheets ride on top of it; any nav to a home/ski exit dismisses onboarding and drops onto the home tab with full state. */ if (inOnboarding) { if (ONBOARDING_EXIT.has(target)) { setInOnboarding(false); setActiveTab('home'); setOnboardingStack([]); return; } if (ONBOARDING_IDS.has(target)) { const idx = onboardingStack.findIndex(s => s.id === target); if (idx === onboardingStack.length - 1) return; if (idx >= 0) { setOnboardingStack(prev => { if (prev[prev.length - 1].leaving) return prev; return prev.map((s, i, arr) => i === arr.length - 1 ? { ...s, leaving: true } : s); }); setTimeout(() => { setOnboardingStack(prev => prev.slice(0, idx + 1)); }, ANIM_MS); return; } setOnboardingStack(prev => [...prev, mk(target, 'push')]); return; } if (SCREENS[target]?.kind === 'sheet') { setOnboardingStack(prev => [...prev, mk(target, 'sheet')]); return; } console.warn('[onboarding] unhandled target:', target); return; } const curStack = tabStacks[activeTab]; const top = curStack[curStack.length - 1]; if (TAB_IDS.includes(target)) { if (target === 'ski' && SKI_START_FROM.has(top.id)) { setTabStacks(prev => ({ ...prev, ski: [...prev.ski, mk('ski-b', 'push')], })); return; } if (activeTab !== target) { if (target === 'rooy') rooyEntryTabRef.current = activeTab; setActiveTab(target); return; } /* Ski tab tap while a ski-flow screen is already on top: stay put. Without this, top=ski-b/ski-c falls through to pop-to-root (presession), and the next tap re-pushes ski-b via the SKI_START_FROM branch above — user sees presession ↔ ski-b ping-ponging on every tap. iOS would also no-op a tab-tap when the user is mid-flow within that tab. */ if (target === 'ski' && (top.id === 'ski-b' || top.id === 'ski-c')) { return; } target = TAB_ROOT[target]; } /* ski-b / ski-c are "long-session" screens that conceptually belong to the ski tab. When invoked from another tab (e.g. rooy chat "开始上课" → ski-c), the default per-tab-stack rule would push them onto the caller's stack — so switching to home and back to ski drops the user on presession (ski tab's untouched root) instead of the session they just started. Re-route: switch to ski tab and ensure the target sits on top of ski's own stack. Other cross-tab targets (daily, review, etc.) stay on the caller's stack as before. */ if ((target === 'ski-b' || target === 'ski-c') && activeTab !== 'ski') { setTabStacks(prev => { const skiStack = prev.ski; const idx = skiStack.findIndex(s => s.id === target); if (idx === skiStack.length - 1) return prev; if (idx >= 0) { return { ...prev, ski: skiStack.slice(0, idx + 1) }; } return { ...prev, ski: [...skiStack, mk(target, 'push')] }; }); setActiveTab('ski'); return; } if (!SCREENS[target]) { console.warn('[RooyNav] unknown screen:', target); return; } const targetMeta = SCREENS[target]; /* Pushes stay on the current tab's stack, even when the target screen's logical home is a different tab (e.g., ski → presession-end → daily; daily belongs to home tab but is pushed onto ski here). The previous design switched activeTab to the target's home tab on push and back on pop, but the back animation ran ON the target tab — the user saw a flash of the target-tab root (home) before the activeTab swap returned them to the caller (ski). Per-tab-local stacks eliminate the flash and match iOS UINavigationController: each tab is its own independent navigation hierarchy. */ /* Same-group replace: review ↔ course ↔ achievement Segmented control. Snap-replace with no animation — iOS UISegmentedControl semantics: the segments themselves animate (pill slide), the content panel underneath swaps instantly. Cross-fading two partially-transparent screens stacks both contents at 0.5 each and reads as a double-image; fading in over the layer beneath would flash whatever's under the academy stack (home). Snap is the only clean option for sibling-tab content. */ const topMeta = SCREENS[top.id]; if (topMeta?.group && topMeta.group === targetMeta.group && top.id !== target) { setTabStacks(prev => ({ ...prev, [activeTab]: [ ...prev[activeTab].slice(0, -1), mk(target, 'none'), ], })); return; } /* Pop to existing screen in current stack. */ const existingIdx = curStack.findIndex(s => s.id === target); if (existingIdx >= 0) { if (existingIdx === curStack.length - 1) return; setTabStacks(prev => ({ ...prev, [activeTab]: prev[activeTab].slice(0, existingIdx + 1), })); return; } if (targetMeta.kind === 'sheet') { setTabStacks(prev => ({ ...prev, [activeTab]: [...prev[activeTab], mk(target, 'sheet')], })); return; } setTabStacks(prev => ({ ...prev, [activeTab]: [...prev[activeTab], mk(target, 'push')], })); }, [activeTab, tabStacks, mk, back, inOnboarding, onboardingStack]); useEffect(() => { const stack = inOnboarding ? onboardingStack : tabStacks[activeTab]; window.RooyNav = { go, back, current: () => stack[stack.length - 1].id, tab: () => inOnboarding ? 'onboarding' : activeTab, }; }, [go, back, tabStacks, activeTab, inOnboarding, onboardingStack]); /* Clear `entering` after the entry animation completes. We need this gate because the top's `isTop && entering && anim==='push'` branch drives push-in: if `entering` stuck around forever, a later pop that re-promotes this layer to top would replay push-in. Clearing it after ANIM_MS lets the post-animation top fall through to a no-class state (natural 0/1) — matching where push-in ended, so no visible jump. The wait is aligned to the oldest entering layer's createdAt (not a flat ANIM_MS) so re-renders during the window — e.g., the academy swap's prune at t=ANIM_MS — re-schedule the timer to the same wall-clock target instead of pushing it out a second ANIM_MS. */ useEffect(() => { const stacks = inOnboarding ? [onboardingStack] : TAB_IDS.map(t => tabStacks[t]); let minStart = Infinity; for (const s of stacks) for (const item of s) { if (item.entering && item.createdAt < minStart) minStart = item.createdAt; } if (minStart === Infinity) return; const wait = Math.max(0, minStart + ANIM_MS - Date.now()); const timer = setTimeout(() => { /* Small fudge above ANIM_MS handles wake-up jitter so layers scheduled in the same tick all clear together. */ const cutoff = Date.now() - ANIM_MS + 16; const clear = (s) => s.some(it => it.entering && it.createdAt <= cutoff) ? s.map(it => it.entering && it.createdAt <= cutoff ? { ...it, entering: false } : it) : s; if (inOnboarding) { setOnboardingStack(prev => clear(prev)); } else { setTabStacks(prev => { let changed = false; const next = { ...prev }; for (const tab of TAB_IDS) { const after = clear(next[tab]); if (after !== next[tab]) { next[tab] = after; changed = true; } } return changed ? next : prev; }); } }, wait); return () => clearTimeout(timer); }, [tabStacks, onboardingStack, inOnboarding]); /* iOS edge-swipe back gesture (only meaningful when active tab has sub-pages on its stack). */ const swipeRef = useRef(null); const onTouchStart = (e) => { if (inOnboarding) { if (onboardingStack.length <= 1) return; } else { const stackLen = tabStacks[activeTab].length; /* Allow swipe on ROOY chat root — back() always has a target. */ if (stackLen <= 1 && activeTab !== 'rooy') return; } const t = e.touches[0]; if (t.clientX > 24) return; swipeRef.current = { x0: t.clientX, y0: t.clientY }; }; const onTouchEnd = (e) => { const s = swipeRef.current; swipeRef.current = null; if (!s) return; const t = e.changedTouches[0]; const dx = t.clientX - s.x0; const dy = Math.abs(t.clientY - s.y0); if (dx > 60 && dy < 40) back(); }; /* Stack renderer. Takes a stack array directly so we can reuse it for the tab stacks (mounted simultaneously, visibility toggle keeps push animations tied to real pushes, not tab swaps) and for the onboarding stack (single full-screen overlay before the user reaches the real app). */ function renderStack(stack) { const topIdx = stack.length - 1; const top = stack[topIdx]; /* Any opaque top covers the under and should hold it in the dimmed parallax state — that includes the snap-replaced academy top (anim:'none'). Only sheets don't, since they float partway over the under with a translucent backdrop. */ const topDrivesUnderlay = top && top.kind !== 'sheet'; return stack.map((item, i) => { const meta = SCREENS[item.id]; if (!meta) return null; const Comp = meta.comp(); if (!Comp) return null; const isTop = i === topIdx; const isSheet = item.kind === 'sheet'; if (isSheet) { const bdClass = item.leaving ? 'anim-fade-out' : 'anim-fade-in'; const panClass = item.leaving ? 'anim-sheet-out' : 'anim-sheet-in'; return (
); } /* Animation resolution: · leaving layer: pop-out for push-style departure, fade-out for cross-fade departure (academy swap marks oldTop with anim:'fade' before flipping leaving). · top entering: push-in or fade-in based on item.anim. Gated on `entering` so this only plays once on the actual push — a later pop that re-promotes the layer won't replay. · layer directly under a push top: push-out (held at -30/0.6 by `animation-fill-mode: both`) while top is in/at-rest, pop-in while top is leaving. Keeping the same keyframe start/end values across push-out → pop-in means no jump when the user reverses direction. */ let animClass = ''; if (item.leaving) { animClass = item.anim === 'fade' ? 'anim-fade-out' : 'anim-pop-out'; } else if (isTop) { if (item.entering) { if (item.anim === 'fade') animClass = 'anim-fade-in'; else if (item.anim === 'push') animClass = 'anim-push-in'; } } else if (i === topIdx - 1 && topDrivesUnderlay) { animClass = top.leaving ? 'anim-pop-in' : 'anim-push-out'; } /* Hide layers deeper than the under. Without this, push-out's opacity 0.6 on the under (and pop-in's opacity-from-0.6 start) leaks content of any deeper layer through the translucent zone — visible as "other screens flashing through" during onboarding pushes, ski-flow transitions, and any cross-tab nav like history → daily. The deeper layers are fully covered by the under in static state, so hiding them is invisible at rest; they unhide whenever the stack reshuffles. IMPORTANT: only set `visibility: hidden` when we actually want to hide. NEVER set `visibility: visible` explicitly — that would override the parent tab container's own `visibility: hidden` (used for inactive tabs), causing every tab's layers to paint on top of the active tab. */ const isHidden = i < topIdx - 1; const layerStyle = { zIndex: i + 1 }; if (isHidden) { layerStyle.visibility = 'hidden'; layerStyle.pointerEvents = 'none'; } return (
); }); } return (
{inOnboarding ? (
{renderStack(onboardingStack)}
) : ( TAB_IDS.map(tabId => { const isActive = activeTab === tabId; return (
{renderStack(tabStacks[tabId])}
); }) )}
); } ReactDOM.createRoot(document.getElementById('app-root')).render();