From aaa8b7e4ececaabef5dbb3921128b3aaed61c865 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 04:46:58 +0000 Subject: [PATCH 1/6] feat: Add interactive tutorial with step-by-step guidance --- src/RailRound.jsx | 31 ++-- src/components/LoginModal.jsx | 2 +- src/components/Tutorial.jsx | 325 ++++++++++++++++++++++++++++++++++ verify_tutorial_structure.js | 53 ++++++ 4 files changed, 400 insertions(+), 11 deletions(-) create mode 100644 src/components/Tutorial.jsx create mode 100644 verify_tutorial_structure.js diff --git a/src/RailRound.jsx b/src/RailRound.jsx index def87f1..08cc0a0 100644 --- a/src/RailRound.jsx +++ b/src/RailRound.jsx @@ -14,6 +14,7 @@ import { LogOut, User, Github } from 'lucide-react'; import { LoginModal } from './components/LoginModal'; +import Tutorial from './components/Tutorial'; import { api } from './services/api'; import { db } from './utils/db'; @@ -794,9 +795,9 @@ const TripEditor = ({ {isEditing ? : } {isEditing ? '编辑行程' : '新行程'} - + -
+
@@ -919,7 +920,7 @@ const PinEditor = ({ editingPin, setEditingPin, pinMode, setPinMode, deletePin, ); }; const FabButton = ({ activeTab, pinMode, togglePinMode }) => ( -
{activeTab === 'map' && ()}
+
{activeTab === 'map' && ()}
); // --- Shared Helper: Calculate Visualization Data --- @@ -1267,7 +1268,7 @@ const RecordsView = ({ trips, railwayData, setTrips, onEdit, onDelete, onAdd, se
); }))} - +
); const StatsView = ({ trips, railwayData ,geoData, user, userProfile, segmentGeometries, onOpenCard, companyDB }) => { @@ -1300,7 +1301,7 @@ const StatsView = ({ trips, railwayData ,geoData, user, userProfile, segmentGeom }); } return ( -
+
{user && (
@@ -2457,7 +2458,7 @@ export default function RailLOOPApp() { {user ? (
欢迎, {user.username} -
@@ -2468,7 +2469,7 @@ export default function RailLOOPApp() { )} {activeTab !== 'map' ? ( -
+
@@ -2481,7 +2482,7 @@ export default function RailLOOPApp() {
) : ( -
+
@@ -2492,7 +2493,7 @@ export default function RailLOOPApp() {
{activeTab === 'records' && { setTripForm({ date: new Date().toISOString().split('T')[0], memo: '', segments: [{ id: Date.now().toString(), lineKey: '', fromId: '', toId: '' }] }); setIsTripEditing(true); }} />} {activeTab === 'stats' && } -
+
@@ -2500,6 +2501,16 @@ export default function RailLOOPApp() {
setIsTripEditing(false)} isEditing={!!editingTripId} form={tripForm} setForm={setTripForm} onSave={handleSaveTrip} railwayData={railwayData} editorMode={editorMode} setEditorMode={setEditorMode} autoForm={autoForm} setAutoForm={setAutoForm} onAutoSearch={handleAutoRouteSearch} isRouteSearching={isRouteSearching} /> + + setIsLoginOpen(false)} onLoginSuccess={handleLoginSuccess} /> setIsGithubRegisterOpen(false)} regToken={githubRegToken} onLoginSuccess={handleLoginSuccess} /> setCardModalUser(null)} /> @@ -2507,7 +2518,7 @@ export default function RailLOOPApp() { {/* Line Selector */} {}} onSelect={() => {}} railwayData={railwayData} />
); diff --git a/src/components/LoginModal.jsx b/src/components/LoginModal.jsx index 495e46e..3b0ee8d 100644 --- a/src/components/LoginModal.jsx +++ b/src/components/LoginModal.jsx @@ -312,7 +312,7 @@ export const LoginModal = ({ isOpen, onClose, onLoginSuccess }) => {
{/* Right: User Guide / Agreement */} -
+
diff --git a/src/components/Tutorial.jsx b/src/components/Tutorial.jsx new file mode 100644 index 0000000..701ecc3 --- /dev/null +++ b/src/components/Tutorial.jsx @@ -0,0 +1,325 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { X, ChevronRight, CheckCircle2, ArrowRight } from 'lucide-react'; + +const Tutorial = ({ + activeTab, + setActiveTab, + isTripEditing, + setIsTripEditing, + isLoginOpen, + setIsLoginOpen, + user +}) => { + const [step, setStep] = useState(-1); // -1: Loading/Check, 0+: Steps + const [rect, setRect] = useState(null); + const [isVisible, setIsVisible] = useState(false); + + // Config + const STEPS = [ + { + id: 'welcome', + target: null, // Center modal + title: "Welcome to RailLOOP", + content: "Welcome! Let's take a quick tour to help you get started with tracking your railway journeys.", + position: 'center', + action: 'next' + }, + { + id: 'tab-records', + target: '#tab-btn-records', + title: "Your Journey Log", + content: "This is the Records tab. Here you can view and manage all your railway trips.", + position: 'top', + action: 'switch-tab', + tab: 'records' + }, + { + id: 'add-trip', + target: '#btn-add-trip', + title: "Record a Trip", + content: "Click here to add a new trip. Let's try opening it now.", + position: 'top', + action: 'wait-interaction', // Wait for user to click + check: () => isTripEditing + }, + { + id: 'editor-modes', + target: '#trip-editor-toggle-mode', + title: "Two Input Modes", + content: "You can manually enter details or use the 'Auto Plan' feature to search for routes.", + position: 'bottom', + action: 'next' + }, + { + id: 'close-editor', + target: '#btn-close-editor', + title: "Close Editor", + content: "Let's close this for now to continue the tour.", + position: 'bottom', + action: 'wait-interaction', + check: () => !isTripEditing + }, + { + id: 'import-export', + target: '#header-actions', + title: "Data Management", + content: "Here you can export your data (KML/JSON) or import backups. Keep your data safe!", + position: 'bottom', + action: 'next' + }, + { + id: 'tab-map', + target: '#tab-btn-map', + title: "Map View", + content: "Switch to the Map tab to see your travels on the map.", + position: 'top', + action: 'switch-tab', + tab: 'map' + }, + { + id: 'map-pins', + target: '#btn-pins-fab', + title: "Map Pins", + content: "Use this button to add pins (photos or comments) to the map.", + position: 'right', // FAB is bottom-left + action: 'next' + }, + { + id: 'map-layers', + target: '.leaflet-control-layers', + title: "Switch Map Style", + content: "Toggle between Dark and Light map themes here.", + position: 'left', // Top-right control + action: 'next' + }, + { + id: 'tab-stats', + target: '#tab-btn-stats', + title: "Statistics", + content: "Finally, check your travel statistics here.", + position: 'top', + action: 'wait-click-tab', // Specifically asked to guide click + tab: 'stats', + check: () => activeTab === 'stats' + }, + { + id: 'stats-content', + target: '#stats-view-content', + title: "Your Achievements", + content: "View your total distance, cost, and most frequented lines here.", + position: 'center', // It's a large area, maybe just highlight without blocking? Or center modal over it? Let's try target. + action: 'next' + }, + { + id: 'finish-login', + target: null, + title: "Login & User Guide", + content: "That's it! We'll now open the Login screen where you can read the full User Guide.", + position: 'center', + action: 'finish' + }, + { + id: 'login-guide', + target: '#login-readme-container', + title: "Read the Guide", + content: "Please take a moment to read the User Guide / Agreement here.", + position: 'left', + action: 'end' + } + ]; + + // Initialization check + useEffect(() => { + const skipped = localStorage.getItem('rail_tutorial_skipped'); + // If user logged in (and likely not new), maybe skip? + // Requirement says "entering page in non-login status". + // If user is already logged in via token in URL or localStorage, we might skip, + // BUT user specifically said "non-login status". + // We will assume `user` prop is null if not logged in. + + if (skipped === 'true' || user) { + setStep(-2); // Skipped + return; + } + + // Start tutorial + setStep(0); + setIsVisible(true); + }, [user]); + + // Step Transition Logic + useEffect(() => { + if (step < 0 || step >= STEPS.length) return; + + const currentStep = STEPS[step]; + + // 1. Target Resolution + const updateRect = () => { + if (!currentStep.target) { + setRect(null); + return; + } + const el = document.querySelector(currentStep.target); + if (el) { + const r = el.getBoundingClientRect(); + // Add padding + setRect({ + top: r.top - 5, + left: r.left - 5, + width: r.width + 10, + height: r.height + 10 + }); + } else { + // Element not found? Retry in a bit (maybe animation or mounting) + setRect(null); + } + }; + + // Initial update + updateRect(); + // Poll for rect changes (animations) + const interval = setInterval(updateRect, 100); + + // 2. Auto-actions (Switch Tab) + // Note: The requirement says "guide to click stats tab". + // For others, we can auto-switch or guide. + // The prompt says "Step by step guidance for 3 tabs". + // Let's Guide instead of Auto-switch for tabs to be interactive, + // except when the element isn't there (e.g. #btn-add-trip is only in Records tab). + // If target is missing, maybe we SHOULD switch tab? + // Let's auto-switch for smoother flow unless it's the specific Stats requirement. + + if (currentStep.action === 'switch-tab' && currentStep.tab && activeTab !== currentStep.tab) { + setActiveTab(currentStep.tab); + } + + // 3. Wait Conditions + let checkInterval; + if (currentStep.check) { + checkInterval = setInterval(() => { + if (currentStep.check()) { + handleNext(); + } + }, 200); + } + + return () => { + clearInterval(interval); + if (checkInterval) clearInterval(checkInterval); + }; + }, [step, activeTab, isTripEditing, isLoginOpen]); + + const handleNext = () => { + if (step === STEPS.length - 2) { // Finish step + // Open Login Modal + setIsLoginOpen(true); + // Wait a bit for modal to open then next + setTimeout(() => setStep(s => s + 1), 500); + } else if (step >= STEPS.length - 1) { + // End + setStep(-2); + setIsVisible(false); + } else { + setStep(s => s + 1); + } + }; + + const handleSkip = (dontShowAgain) => { + if (dontShowAgain) { + localStorage.setItem('rail_tutorial_skipped', 'true'); + } + setStep(-2); + setIsVisible(false); + }; + + if (!isVisible || step < 0) return null; + + const currentStep = STEPS[step]; + + // Render + return createPortal( +
+ {/* Mask / Spotlight */} + {rect ? ( +
+ ) : ( + // Full overlay if no target (Welcome step) +
+ )} + + {/* Tooltip / Card */} +
+
+
+

{currentStep.title}

+ {step + 1} / {STEPS.length} +
+

+ {currentStep.content} +

+ +
+
+ {step === 0 && ( + + )} + {step > 0 && ( + + )} +
+ + {currentStep.action !== 'wait-interaction' && currentStep.action !== 'wait-click-tab' && ( + + )} + {(currentStep.action === 'wait-interaction' || currentStep.action === 'wait-click-tab') && ( +
+ Please interact to continue... +
+ )} +
+
+
+
+ , document.body); +}; + +export default Tutorial; diff --git a/verify_tutorial_structure.js b/verify_tutorial_structure.js new file mode 100644 index 0000000..ebf3adb --- /dev/null +++ b/verify_tutorial_structure.js @@ -0,0 +1,53 @@ + +const fs = require('fs'); +const path = require('path'); + +const railRoundPath = path.join('src', 'RailRound.jsx'); +const loginModalPath = path.join('src', 'components', 'LoginModal.jsx'); +const tutorialPath = path.join('src', 'components', 'Tutorial.jsx'); + +function checkFileExists(p) { + if (fs.existsSync(p)) { + console.log(`[OK] ${p} exists.`); + } else { + console.error(`[FAIL] ${p} does not exist.`); + process.exit(1); + } +} + +function checkContent(p, searchString) { + const content = fs.readFileSync(p, 'utf-8'); + if (content.includes(searchString)) { + console.log(`[OK] Found "${searchString}" in ${p}`); + } else { + console.error(`[FAIL] Could not find "${searchString}" in ${p}`); + process.exit(1); + } +} + +console.log("Verifying Tutorial Implementation..."); + +// 1. Check Files +checkFileExists(railRoundPath); +checkFileExists(loginModalPath); +checkFileExists(tutorialPath); + +// 2. Check RailRound IDs and Import +checkContent(railRoundPath, 'import Tutorial from \'./components/Tutorial\''); +checkContent(railRoundPath, ' Date: Thu, 8 Jan 2026 05:42:41 +0000 Subject: [PATCH 2/6] feat: Add interactive tutorial with step-by-step guidance --- src/components/Tutorial.jsx | 101 +++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 29 deletions(-) diff --git a/src/components/Tutorial.jsx b/src/components/Tutorial.jsx index 701ecc3..1187186 100644 --- a/src/components/Tutorial.jsx +++ b/src/components/Tutorial.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { X, ChevronRight, CheckCircle2, ArrowRight } from 'lucide-react'; +import { ChevronRight, ArrowRight } from 'lucide-react'; const Tutorial = ({ activeTab, @@ -14,6 +14,7 @@ const Tutorial = ({ const [step, setStep] = useState(-1); // -1: Loading/Check, 0+: Steps const [rect, setRect] = useState(null); const [isVisible, setIsVisible] = useState(false); + const [tooltipPos, setTooltipPos] = useState({}); // Config const STEPS = [ @@ -158,18 +159,79 @@ const Tutorial = ({ const updateRect = () => { if (!currentStep.target) { setRect(null); + setTooltipPos({ top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }); return; } const el = document.querySelector(currentStep.target); if (el) { const r = el.getBoundingClientRect(); - // Add padding - setRect({ + const PADDING = 20; + const CARD_W = 384; // max-w-sm approx + const CARD_H = 250; // approx height estimate + + // Add padding to highlight box + const highlight = { top: r.top - 5, left: r.left - 5, width: r.width + 10, height: r.height + 10 - }); + }; + setRect(highlight); + + // Calculate Tooltip Position with Clamping + let top, left, transform = 'none'; + + if (currentStep.position === 'bottom') { + top = highlight.top + highlight.height + 20; + left = highlight.left; + } else if (currentStep.position === 'top') { + top = highlight.top - CARD_H - 20; + left = highlight.left; + } else if (currentStep.position === 'right') { + top = highlight.top; + left = highlight.left + highlight.width + 20; + } else if (currentStep.position === 'left') { + top = highlight.top; + left = highlight.left - CARD_W - 20; + } else { // center + top = '50%'; + left = '50%'; + transform = 'translate(-50%, -50%)'; + } + + // Clamping Logic + if (typeof top === 'number') { + // Vertical Clamp + if (top < PADDING) top = PADDING; + if (top + CARD_H > window.innerHeight - PADDING) { + // Try flipping if overflowing bottom + if (currentStep.position === 'bottom') { + const flippedTop = highlight.top - CARD_H - 20; + if (flippedTop > PADDING) top = flippedTop; + else top = window.innerHeight - CARD_H - PADDING; + } else { + top = window.innerHeight - CARD_H - PADDING; + } + } + } + + if (typeof left === 'number') { + // Horizontal Clamp + if (left < PADDING) left = PADDING; + if (left + CARD_W > window.innerWidth - PADDING) { + // Try flipping if overflowing right + if (currentStep.position === 'right') { + const flippedLeft = highlight.left - CARD_W - 20; + if (flippedLeft > PADDING) left = flippedLeft; + else left = window.innerWidth - CARD_W - PADDING; + } else { + left = window.innerWidth - CARD_W - PADDING; + } + } + } + + setTooltipPos({ top, left, transform }); + } else { // Element not found? Retry in a bit (maybe animation or mounting) setRect(null); @@ -182,14 +244,6 @@ const Tutorial = ({ const interval = setInterval(updateRect, 100); // 2. Auto-actions (Switch Tab) - // Note: The requirement says "guide to click stats tab". - // For others, we can auto-switch or guide. - // The prompt says "Step by step guidance for 3 tabs". - // Let's Guide instead of Auto-switch for tabs to be interactive, - // except when the element isn't there (e.g. #btn-add-trip is only in Records tab). - // If target is missing, maybe we SHOULD switch tab? - // Let's auto-switch for smoother flow unless it's the specific Stats requirement. - if (currentStep.action === 'switch-tab' && currentStep.tab && activeTab !== currentStep.tab) { setActiveTab(currentStep.tab); } @@ -236,6 +290,7 @@ const Tutorial = ({ if (!isVisible || step < 0) return null; const currentStep = STEPS[step]; + const isInteractionStep = currentStep.action === 'wait-interaction' || currentStep.action === 'wait-click-tab'; // Render return createPortal( @@ -263,21 +318,9 @@ const Tutorial = ({
@@ -302,7 +345,7 @@ const Tutorial = ({ )}
- {currentStep.action !== 'wait-interaction' && currentStep.action !== 'wait-click-tab' && ( + {!isInteractionStep && ( )} - {(currentStep.action === 'wait-interaction' || currentStep.action === 'wait-click-tab') && ( + {isInteractionStep && (
Please interact to continue...
From b57e966cffc470a9dba59780f6b08daf3bae7580 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 05:50:28 +0000 Subject: [PATCH 3/6] fix: Improve tutorial UX and positioning strictness --- src/components/Tutorial.jsx | 151 +++++++++++++++++++++--------------- verify_tutorial_logic.js | 40 ++++++++++ 2 files changed, 130 insertions(+), 61 deletions(-) create mode 100644 verify_tutorial_logic.js diff --git a/src/components/Tutorial.jsx b/src/components/Tutorial.jsx index 1187186..c68e640 100644 --- a/src/components/Tutorial.jsx +++ b/src/components/Tutorial.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useLayoutEffect } from 'react'; import { createPortal } from 'react-dom'; import { ChevronRight, ArrowRight } from 'lucide-react'; @@ -16,6 +16,9 @@ const Tutorial = ({ const [isVisible, setIsVisible] = useState(false); const [tooltipPos, setTooltipPos] = useState({}); + // Ref for the tooltip card to measure strict dimensions + const tooltipRef = useRef(null); + // Config const STEPS = [ { @@ -149,7 +152,7 @@ const Tutorial = ({ setIsVisible(true); }, [user]); - // Step Transition Logic + // Step Transition Logic & Rect Calculation useEffect(() => { if (step < 0 || step >= STEPS.length) return; @@ -165,9 +168,6 @@ const Tutorial = ({ const el = document.querySelector(currentStep.target); if (el) { const r = el.getBoundingClientRect(); - const PADDING = 20; - const CARD_W = 384; // max-w-sm approx - const CARD_H = 250; // approx height estimate // Add padding to highlight box const highlight = { @@ -177,61 +177,6 @@ const Tutorial = ({ height: r.height + 10 }; setRect(highlight); - - // Calculate Tooltip Position with Clamping - let top, left, transform = 'none'; - - if (currentStep.position === 'bottom') { - top = highlight.top + highlight.height + 20; - left = highlight.left; - } else if (currentStep.position === 'top') { - top = highlight.top - CARD_H - 20; - left = highlight.left; - } else if (currentStep.position === 'right') { - top = highlight.top; - left = highlight.left + highlight.width + 20; - } else if (currentStep.position === 'left') { - top = highlight.top; - left = highlight.left - CARD_W - 20; - } else { // center - top = '50%'; - left = '50%'; - transform = 'translate(-50%, -50%)'; - } - - // Clamping Logic - if (typeof top === 'number') { - // Vertical Clamp - if (top < PADDING) top = PADDING; - if (top + CARD_H > window.innerHeight - PADDING) { - // Try flipping if overflowing bottom - if (currentStep.position === 'bottom') { - const flippedTop = highlight.top - CARD_H - 20; - if (flippedTop > PADDING) top = flippedTop; - else top = window.innerHeight - CARD_H - PADDING; - } else { - top = window.innerHeight - CARD_H - PADDING; - } - } - } - - if (typeof left === 'number') { - // Horizontal Clamp - if (left < PADDING) left = PADDING; - if (left + CARD_W > window.innerWidth - PADDING) { - // Try flipping if overflowing right - if (currentStep.position === 'right') { - const flippedLeft = highlight.left - CARD_W - 20; - if (flippedLeft > PADDING) left = flippedLeft; - else left = window.innerWidth - CARD_W - PADDING; - } else { - left = window.innerWidth - CARD_W - PADDING; - } - } - } - - setTooltipPos({ top, left, transform }); - } else { // Element not found? Retry in a bit (maybe animation or mounting) setRect(null); @@ -264,6 +209,88 @@ const Tutorial = ({ }; }, [step, activeTab, isTripEditing, isLoginOpen]); + // Strict Positioning Logic + useLayoutEffect(() => { + if (step < 0 || step >= STEPS.length || !rect || !tooltipRef.current) return; + + const currentStep = STEPS[step]; + const PADDING = 20; + + // Measure ACTUAL tooltip dimensions + const CARD_W = tooltipRef.current.offsetWidth || 384; + const CARD_H = tooltipRef.current.offsetHeight || 250; + + let top, left, transform = 'none'; + + if (currentStep.position === 'bottom') { + top = rect.top + rect.height + 20; + left = rect.left; + } else if (currentStep.position === 'top') { + top = rect.top - CARD_H - 20; + left = rect.left; + } else if (currentStep.position === 'right') { + top = rect.top; + left = rect.left + rect.width + 20; + } else if (currentStep.position === 'left') { + top = rect.top; + left = rect.left - CARD_W - 20; + } else { // center + top = '50%'; + left = '50%'; + transform = 'translate(-50%, -50%)'; + } + + // --- Strict Boundary Logic --- + const winH = window.innerHeight; + const winW = window.innerWidth; + + if (typeof top === 'number') { + // 1. Flip Logic (Vertical) + // If overflow bottom, try flip top + if (top + CARD_H > winH - PADDING) { + if (currentStep.position === 'bottom') { + const flippedTop = rect.top - CARD_H - 20; + // Only flip if there is space on top + if (flippedTop > PADDING) top = flippedTop; + } + } + // If overflow top (e.g. from flip or 'top' pos), try flip bottom + if (top < PADDING) { + if (currentStep.position === 'top') { + const flippedBottom = rect.top + rect.height + 20; + if (flippedBottom + CARD_H < winH - PADDING) top = flippedBottom; + } + } + + // 2. Clamp Logic (Vertical) - Final fallback + if (top < PADDING) top = PADDING; + if (top + CARD_H > winH - PADDING) top = winH - CARD_H - PADDING; + } + + if (typeof left === 'number') { + // 1. Flip Logic (Horizontal) + if (left + CARD_W > winW - PADDING) { + if (currentStep.position === 'right') { + const flippedLeft = rect.left - CARD_W - 20; + if (flippedLeft > PADDING) left = flippedLeft; + } + } + if (left < PADDING) { + if (currentStep.position === 'left') { + const flippedRight = rect.left + rect.width + 20; + if (flippedRight + CARD_W < winW - PADDING) left = flippedRight; + } + } + + // 2. Clamp Logic (Horizontal) + if (left < PADDING) left = PADDING; + if (left + CARD_W > winW - PADDING) left = winW - CARD_W - PADDING; + } + + setTooltipPos({ top, left, transform }); + + }, [rect, step]); // Re-run when rect changes (target moves) or step changes (content changes) + const handleNext = () => { if (step === STEPS.length - 2) { // Finish step // Open Login Modal @@ -316,11 +343,13 @@ const Tutorial = ({ {/* Tooltip / Card */}
diff --git a/verify_tutorial_logic.js b/verify_tutorial_logic.js new file mode 100644 index 0000000..ad44c14 --- /dev/null +++ b/verify_tutorial_logic.js @@ -0,0 +1,40 @@ + +const fs = require('fs'); +const path = require('path'); + +const tutorialPath = path.join('src', 'components', 'Tutorial.jsx'); + +console.log("Verifying Tutorial Logic..."); + +if (!fs.existsSync(tutorialPath)) { + console.error("Tutorial.jsx not found"); + process.exit(1); +} + +const content = fs.readFileSync(tutorialPath, 'utf-8'); + +// 1. Check for measure logic (dynamic size) +if (content.includes('tooltipRef.current.offsetWidth') && content.includes('useLayoutEffect')) { + console.log("[OK] Dynamic sizing logic found (useLayoutEffect + offsetWidth)"); +} else { + console.error("[FAIL] Dynamic sizing logic missing"); + process.exit(1); +} + +// 2. Check for Flip/Clamp logic +if (content.includes('flippedTop') && content.includes('winH - PADDING')) { + console.log("[OK] Boundary Flip/Clamp logic found"); +} else { + console.error("[FAIL] Boundary logic missing"); + process.exit(1); +} + +// 3. Check interaction logic (hiding button) +if (content.includes('!isInteractionStep &&')) { + console.log("[OK] Next button hiding logic found"); +} else { + console.error("[FAIL] Next button logic missing"); + process.exit(1); +} + +console.log("Verification Successful."); From c698ba74e1cdae2b84862d29e11764a53b242a91 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 06:22:59 +0000 Subject: [PATCH 4/6] fix: Robust tutorial positioning and interactive map pin step --- src/RailRound.jsx | 1 + src/components/Tutorial.jsx | 45 ++++++++++++++++++++++++++++--------- verify_tutorial_logic.js | 24 ++++++++++---------- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/RailRound.jsx b/src/RailRound.jsx index 08cc0a0..b26ae22 100644 --- a/src/RailRound.jsx +++ b/src/RailRound.jsx @@ -2509,6 +2509,7 @@ export default function RailLOOPApp() { isLoginOpen={isLoginOpen} setIsLoginOpen={setIsLoginOpen} user={user} + pinMode={pinMode} /> setIsLoginOpen(false)} onLoginSuccess={handleLoginSuccess} /> diff --git a/src/components/Tutorial.jsx b/src/components/Tutorial.jsx index c68e640..40c4fe3 100644 --- a/src/components/Tutorial.jsx +++ b/src/components/Tutorial.jsx @@ -9,7 +9,8 @@ const Tutorial = ({ setIsTripEditing, isLoginOpen, setIsLoginOpen, - user + user, + pinMode }) => { const [step, setStep] = useState(-1); // -1: Loading/Check, 0+: Steps const [rect, setRect] = useState(null); @@ -87,7 +88,8 @@ const Tutorial = ({ title: "Map Pins", content: "Use this button to add pins (photos or comments) to the map.", position: 'right', // FAB is bottom-left - action: 'next' + action: 'wait-interaction', + check: () => pinMode && pinMode !== 'idle' }, { id: 'map-layers', @@ -207,10 +209,10 @@ const Tutorial = ({ clearInterval(interval); if (checkInterval) clearInterval(checkInterval); }; - }, [step, activeTab, isTripEditing, isLoginOpen]); + }, [step, activeTab, isTripEditing, isLoginOpen, pinMode]); // Strict Positioning Logic - useLayoutEffect(() => { + const calculatePosition = () => { if (step < 0 || step >= STEPS.length || !rect || !tooltipRef.current) return; const currentStep = STEPS[step]; @@ -229,10 +231,12 @@ const Tutorial = ({ top = rect.top - CARD_H - 20; left = rect.left; } else if (currentStep.position === 'right') { - top = rect.top; + // Vertically center for side positioning + top = rect.top + (rect.height / 2) - (CARD_H / 2); left = rect.left + rect.width + 20; } else if (currentStep.position === 'left') { - top = rect.top; + // Vertically center for side positioning + top = rect.top + (rect.height / 2) - (CARD_H / 2); left = rect.left - CARD_W - 20; } else { // center top = '50%'; @@ -241,8 +245,15 @@ const Tutorial = ({ } // --- Strict Boundary Logic --- - const winH = window.innerHeight; - const winW = window.innerWidth; + // Use visualViewport if available for mobile robustness + const winH = window.visualViewport ? window.visualViewport.height : window.innerHeight; + const winW = window.visualViewport ? window.visualViewport.width : window.innerWidth; + const offsetTop = window.visualViewport ? window.visualViewport.offsetTop : 0; + + // Adjust for viewport scrolling/offset if using visualViewport + // Actually for fixed position elements, visualViewport logic is complex. + // If we use simple innerHeight, it might be safer unless keyboard is up. + // But for FAB (bottom) clipping, visualViewport height is the visible part. if (typeof top === 'number') { // 1. Flip Logic (Vertical) @@ -250,11 +261,10 @@ const Tutorial = ({ if (top + CARD_H > winH - PADDING) { if (currentStep.position === 'bottom') { const flippedTop = rect.top - CARD_H - 20; - // Only flip if there is space on top if (flippedTop > PADDING) top = flippedTop; } } - // If overflow top (e.g. from flip or 'top' pos), try flip bottom + // If overflow top, try flip bottom if (top < PADDING) { if (currentStep.position === 'top') { const flippedBottom = rect.top + rect.height + 20; @@ -288,8 +298,21 @@ const Tutorial = ({ } setTooltipPos({ top, left, transform }); + }; + + useLayoutEffect(() => { + calculatePosition(); + }, [rect, step]); - }, [rect, step]); // Re-run when rect changes (target moves) or step changes (content changes) + // ResizeObserver to handle content size changes + useEffect(() => { + if (!tooltipRef.current) return; + const observer = new ResizeObserver(() => { + calculatePosition(); + }); + observer.observe(tooltipRef.current); + return () => observer.disconnect(); + }, [rect, step]); const handleNext = () => { if (step === STEPS.length - 2) { // Finish step diff --git a/verify_tutorial_logic.js b/verify_tutorial_logic.js index ad44c14..861833c 100644 --- a/verify_tutorial_logic.js +++ b/verify_tutorial_logic.js @@ -13,27 +13,27 @@ if (!fs.existsSync(tutorialPath)) { const content = fs.readFileSync(tutorialPath, 'utf-8'); -// 1. Check for measure logic (dynamic size) -if (content.includes('tooltipRef.current.offsetWidth') && content.includes('useLayoutEffect')) { - console.log("[OK] Dynamic sizing logic found (useLayoutEffect + offsetWidth)"); +// 1. Check for visualViewport usage +if (content.includes('window.visualViewport.height')) { + console.log("[OK] visualViewport usage found"); } else { - console.error("[FAIL] Dynamic sizing logic missing"); + console.error("[FAIL] visualViewport usage missing"); process.exit(1); } -// 2. Check for Flip/Clamp logic -if (content.includes('flippedTop') && content.includes('winH - PADDING')) { - console.log("[OK] Boundary Flip/Clamp logic found"); +// 2. Check for ResizeObserver usage +if (content.includes('new ResizeObserver')) { + console.log("[OK] ResizeObserver usage found"); } else { - console.error("[FAIL] Boundary logic missing"); + console.error("[FAIL] ResizeObserver usage missing"); process.exit(1); } -// 3. Check interaction logic (hiding button) -if (content.includes('!isInteractionStep &&')) { - console.log("[OK] Next button hiding logic found"); +// 3. Check for map-pins interactive mode +if (content.includes('id: \'map-pins\'') && content.includes('action: \'wait-interaction\'') && content.includes('pinMode')) { + console.log("[OK] Map pins interactive mode configured"); } else { - console.error("[FAIL] Next button logic missing"); + console.error("[FAIL] Map pins configuration incorrect"); process.exit(1); } From f37292acfd385b22b50063873d1a96afc78b88cc Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 06:37:01 +0000 Subject: [PATCH 5/6] feat: Refine tutorial with centering priority and stability improvements --- src/components/Tutorial.jsx | 26 ++++++---------------- verify_tutorial_refinement.js | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 19 deletions(-) create mode 100644 verify_tutorial_refinement.js diff --git a/src/components/Tutorial.jsx b/src/components/Tutorial.jsx index 40c4fe3..6446ffb 100644 --- a/src/components/Tutorial.jsx +++ b/src/components/Tutorial.jsx @@ -226,17 +226,15 @@ const Tutorial = ({ if (currentStep.position === 'bottom') { top = rect.top + rect.height + 20; - left = rect.left; + left = rect.left + (rect.width / 2) - (CARD_W / 2); // Center horizontally } else if (currentStep.position === 'top') { top = rect.top - CARD_H - 20; - left = rect.left; + left = rect.left + (rect.width / 2) - (CARD_W / 2); // Center horizontally } else if (currentStep.position === 'right') { - // Vertically center for side positioning - top = rect.top + (rect.height / 2) - (CARD_H / 2); + top = rect.top + (rect.height / 2) - (CARD_H / 2); // Center vertically left = rect.left + rect.width + 20; } else if (currentStep.position === 'left') { - // Vertically center for side positioning - top = rect.top + (rect.height / 2) - (CARD_H / 2); + top = rect.top + (rect.height / 2) - (CARD_H / 2); // Center vertically left = rect.left - CARD_W - 20; } else { // center top = '50%'; @@ -245,34 +243,24 @@ const Tutorial = ({ } // --- Strict Boundary Logic --- - // Use visualViewport if available for mobile robustness const winH = window.visualViewport ? window.visualViewport.height : window.innerHeight; const winW = window.visualViewport ? window.visualViewport.width : window.innerWidth; - const offsetTop = window.visualViewport ? window.visualViewport.offsetTop : 0; - - // Adjust for viewport scrolling/offset if using visualViewport - // Actually for fixed position elements, visualViewport logic is complex. - // If we use simple innerHeight, it might be safer unless keyboard is up. - // But for FAB (bottom) clipping, visualViewport height is the visible part. if (typeof top === 'number') { // 1. Flip Logic (Vertical) - // If overflow bottom, try flip top if (top + CARD_H > winH - PADDING) { if (currentStep.position === 'bottom') { const flippedTop = rect.top - CARD_H - 20; if (flippedTop > PADDING) top = flippedTop; } } - // If overflow top, try flip bottom if (top < PADDING) { if (currentStep.position === 'top') { const flippedBottom = rect.top + rect.height + 20; if (flippedBottom + CARD_H < winH - PADDING) top = flippedBottom; } } - - // 2. Clamp Logic (Vertical) - Final fallback + // 2. Clamp Logic (Vertical) if (top < PADDING) top = PADDING; if (top + CARD_H > winH - PADDING) top = winH - CARD_H - PADDING; } @@ -291,7 +279,6 @@ const Tutorial = ({ if (flippedRight + CARD_W < winW - PADDING) left = flippedRight; } } - // 2. Clamp Logic (Horizontal) if (left < PADDING) left = PADDING; if (left + CARD_W > winW - PADDING) left = winW - CARD_W - PADDING; @@ -372,7 +359,8 @@ const Tutorial = ({ top: tooltipPos.top, left: tooltipPos.left, transform: tooltipPos.transform, - opacity: tooltipPos.top ? 1 : 0 // Hide until positioned + opacity: tooltipPos.top ? 1 : 0, // Hide until positioned + transitionTimingFunction: 'cubic-bezier(0.25, 1, 0.5, 1)' // Bessel curve (Bezier) }} >
diff --git a/verify_tutorial_refinement.js b/verify_tutorial_refinement.js new file mode 100644 index 0000000..5392351 --- /dev/null +++ b/verify_tutorial_refinement.js @@ -0,0 +1,42 @@ + +const fs = require('fs'); +const path = require('path'); + +const tutorialPath = path.join('src', 'components', 'Tutorial.jsx'); + +console.log("Verifying Tutorial Refinements..."); + +if (!fs.existsSync(tutorialPath)) { + console.error("Tutorial.jsx not found"); + process.exit(1); +} + +const content = fs.readFileSync(tutorialPath, 'utf-8'); + +// 1. Check for Bezier Curve +if (content.includes('cubic-bezier(0.25, 1, 0.5, 1)')) { + console.log("[OK] Bezier curve animation found"); +} else { + console.error("[FAIL] Bezier curve animation missing"); + process.exit(1); +} + +// 2. Check for Horizontal Centering (Top/Bottom) +// Look for (rect.width / 2) - (CARD_W / 2) +if (content.includes('rect.left + (rect.width / 2) - (CARD_W / 2)')) { + console.log("[OK] Horizontal centering logic found"); +} else { + console.error("[FAIL] Horizontal centering logic missing"); + process.exit(1); +} + +// 3. Check for Vertical Centering (Left/Right) +// Look for (rect.height / 2) - (CARD_H / 2) +if (content.includes('rect.top + (rect.height / 2) - (CARD_H / 2)')) { + console.log("[OK] Vertical centering logic found"); +} else { + console.error("[FAIL] Vertical centering logic missing"); + process.exit(1); +} + +console.log("Verification Successful."); From 13ba0a586c01d93008658703a16eb593ab3a8925 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 07:38:51 +0000 Subject: [PATCH 6/6] fix: Improve tutorial positioning with strict clamping and centering --- src/components/Tutorial.jsx | 150 ++++++++++++++++++---------------- verify_tutorial_refinement.js | 26 +++--- 2 files changed, 91 insertions(+), 85 deletions(-) diff --git a/src/components/Tutorial.jsx b/src/components/Tutorial.jsx index 6446ffb..be417f7 100644 --- a/src/components/Tutorial.jsx +++ b/src/components/Tutorial.jsx @@ -15,7 +15,7 @@ const Tutorial = ({ const [step, setStep] = useState(-1); // -1: Loading/Check, 0+: Steps const [rect, setRect] = useState(null); const [isVisible, setIsVisible] = useState(false); - const [tooltipPos, setTooltipPos] = useState({}); + const [tooltipStyle, setTooltipStyle] = useState({}); // Ref for the tooltip card to measure strict dimensions const tooltipRef = useRef(null); @@ -138,18 +138,10 @@ const Tutorial = ({ // Initialization check useEffect(() => { const skipped = localStorage.getItem('rail_tutorial_skipped'); - // If user logged in (and likely not new), maybe skip? - // Requirement says "entering page in non-login status". - // If user is already logged in via token in URL or localStorage, we might skip, - // BUT user specifically said "non-login status". - // We will assume `user` prop is null if not logged in. - if (skipped === 'true' || user) { setStep(-2); // Skipped return; } - - // Start tutorial setStep(0); setIsVisible(true); }, [user]); @@ -164,7 +156,14 @@ const Tutorial = ({ const updateRect = () => { if (!currentStep.target) { setRect(null); - setTooltipPos({ top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }); + setTooltipStyle({ + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + opacity: 1, + transition: 'all 0.5s cubic-bezier(0.25, 1, 0.5, 1)' + }); return; } const el = document.querySelector(currentStep.target); @@ -222,69 +221,84 @@ const Tutorial = ({ const CARD_W = tooltipRef.current.offsetWidth || 384; const CARD_H = tooltipRef.current.offsetHeight || 250; - let top, left, transform = 'none'; - - if (currentStep.position === 'bottom') { - top = rect.top + rect.height + 20; - left = rect.left + (rect.width / 2) - (CARD_W / 2); // Center horizontally - } else if (currentStep.position === 'top') { - top = rect.top - CARD_H - 20; - left = rect.left + (rect.width / 2) - (CARD_W / 2); // Center horizontally - } else if (currentStep.position === 'right') { - top = rect.top + (rect.height / 2) - (CARD_H / 2); // Center vertically - left = rect.left + rect.width + 20; - } else if (currentStep.position === 'left') { - top = rect.top + (rect.height / 2) - (CARD_H / 2); // Center vertically - left = rect.left - CARD_W - 20; - } else { // center - top = '50%'; - left = '50%'; - transform = 'translate(-50%, -50%)'; - } - - // --- Strict Boundary Logic --- + // Viewport Dimensions const winH = window.visualViewport ? window.visualViewport.height : window.innerHeight; const winW = window.visualViewport ? window.visualViewport.width : window.innerWidth; - if (typeof top === 'number') { - // 1. Flip Logic (Vertical) - if (top + CARD_H > winH - PADDING) { - if (currentStep.position === 'bottom') { - const flippedTop = rect.top - CARD_H - 20; - if (flippedTop > PADDING) top = flippedTop; - } - } - if (top < PADDING) { - if (currentStep.position === 'top') { - const flippedBottom = rect.top + rect.height + 20; - if (flippedBottom + CARD_H < winH - PADDING) top = flippedBottom; - } + // Center of the target element + const targetCenterX = rect.left + rect.width / 2; + const targetCenterY = rect.top + rect.height / 2; + + let top = 0; + let left = 0; + let pos = currentStep.position; + + // Helper to check collision + const checkBounds = (t, l) => { + return (t >= PADDING && l >= PADDING && (t + CARD_H) <= (winH - PADDING) && (l + CARD_W) <= (winW - PADDING)); + }; + + // 1. Determine Initial Position & Flip if needed + const getCoords = (p) => { + let t, l; + if (p === 'bottom') { + t = rect.top + rect.height + 20; + l = targetCenterX - (CARD_W / 2); + } else if (p === 'top') { + t = rect.top - CARD_H - 20; + l = targetCenterX - (CARD_W / 2); + } else if (p === 'right') { + t = targetCenterY - (CARD_H / 2); + l = rect.left + rect.width + 20; + } else if (p === 'left') { + t = targetCenterY - (CARD_H / 2); + l = rect.left - CARD_W - 20; + } else { // center + t = winH / 2 - CARD_H / 2; + l = winW / 2 - CARD_W / 2; } - // 2. Clamp Logic (Vertical) - if (top < PADDING) top = PADDING; - if (top + CARD_H > winH - PADDING) top = winH - CARD_H - PADDING; - } + return { t, l }; + }; - if (typeof left === 'number') { - // 1. Flip Logic (Horizontal) - if (left + CARD_W > winW - PADDING) { - if (currentStep.position === 'right') { - const flippedLeft = rect.left - CARD_W - 20; - if (flippedLeft > PADDING) left = flippedLeft; + let coords = getCoords(pos); + + // Smart Flip + if (!checkBounds(coords.t, coords.l) && pos !== 'center') { + const opposites = { 'top': 'bottom', 'bottom': 'top', 'left': 'right', 'right': 'left' }; + const altPos = opposites[pos]; + if (altPos) { + const altCoords = getCoords(altPos); + // If alternative is better (or valid), take it + // Simple heuristic: check if alt fits vertically for top/bottom swap + if (pos === 'top' || pos === 'bottom') { + if (altCoords.t >= PADDING && (altCoords.t + CARD_H) <= (winH - PADDING)) { + coords = altCoords; + pos = altPos; + } } - } - if (left < PADDING) { - if (currentStep.position === 'left') { - const flippedRight = rect.left + rect.width + 20; - if (flippedRight + CARD_W < winW - PADDING) left = flippedRight; + // Check if alt fits horizontally for left/right swap + if (pos === 'left' || pos === 'right') { + if (altCoords.l >= PADDING && (altCoords.l + CARD_W) <= (winW - PADDING)) { + coords = altCoords; + pos = altPos; + } } } - // 2. Clamp Logic (Horizontal) - if (left < PADDING) left = PADDING; - if (left + CARD_W > winW - PADDING) left = winW - CARD_W - PADDING; } - setTooltipPos({ top, left, transform }); + // 2. Strict Clamping (The "Aggressive" Part) + // Ensure card never goes off screen, even if it de-centers from target + top = Math.max(PADDING, Math.min(coords.t, winH - CARD_H - PADDING)); + left = Math.max(PADDING, Math.min(coords.l, winW - CARD_W - PADDING)); + + setTooltipStyle({ + position: 'fixed', + top: `${top}px`, + left: `${left}px`, + transform: 'none', + opacity: 1, + transition: 'all 0.4s cubic-bezier(0.25, 1, 0.5, 1)' // Smooth transitions + }); }; useLayoutEffect(() => { @@ -354,14 +368,8 @@ const Tutorial = ({ {/* Tooltip / Card */}
diff --git a/verify_tutorial_refinement.js b/verify_tutorial_refinement.js index 5392351..c62b23c 100644 --- a/verify_tutorial_refinement.js +++ b/verify_tutorial_refinement.js @@ -13,29 +13,27 @@ if (!fs.existsSync(tutorialPath)) { const content = fs.readFileSync(tutorialPath, 'utf-8'); -// 1. Check for Bezier Curve -if (content.includes('cubic-bezier(0.25, 1, 0.5, 1)')) { - console.log("[OK] Bezier curve animation found"); +// 1. Check for tooltipStyle usage +if (content.includes('style={tooltipStyle}')) { + console.log("[OK] Dynamic style usage found"); } else { - console.error("[FAIL] Bezier curve animation missing"); + console.error("[FAIL] Dynamic style usage missing"); process.exit(1); } -// 2. Check for Horizontal Centering (Top/Bottom) -// Look for (rect.width / 2) - (CARD_W / 2) -if (content.includes('rect.left + (rect.width / 2) - (CARD_W / 2)')) { - console.log("[OK] Horizontal centering logic found"); +// 2. Check for Math.max clamping logic +if (content.includes('Math.max(PADDING, Math.min')) { + console.log("[OK] Strict Clamping logic found"); } else { - console.error("[FAIL] Horizontal centering logic missing"); + console.error("[FAIL] Strict Clamping logic missing"); process.exit(1); } -// 3. Check for Vertical Centering (Left/Right) -// Look for (rect.height / 2) - (CARD_H / 2) -if (content.includes('rect.top + (rect.height / 2) - (CARD_H / 2)')) { - console.log("[OK] Vertical centering logic found"); +// 3. Check for Smart Flip logic +if (content.includes('const opposites = { \'top\': \'bottom\'')) { + console.log("[OK] Smart Flip logic found"); } else { - console.error("[FAIL] Vertical centering logic missing"); + console.error("[FAIL] Smart Flip logic missing"); process.exit(1); }