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);
}