Skip to content
Open
37 changes: 20 additions & 17 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import UpdateNotice from "@/components/UpdateNotice";
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { ApiProvider } from "./contexts/ApiContext";
import { HfAuthProvider } from "./contexts/HfAuthContext";
import { OnboardingProvider } from "./contexts/OnboardingContext";

const queryClient = new QueryClient();

Expand All @@ -33,24 +34,26 @@ function App() {
<UrdfProvider>
<DragAndDropProvider>
<BrowserRouter>
<SingleTabGuard>
<TeleopStopNotice />
<UpdateNotice />
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/teleoperation" element={<Teleoperation />} />
<Route path="/recording" element={<Recording />} />
<Route path="/upload" element={<Upload />} />
<Route path="/training" element={<Training />} />
<Route path="/training/:jobId" element={<Training />} />
<Route path="/inference" element={<Inference />} />
<Route path="/calibration" element={<Calibration />} />
<Route path="/edit-dataset" element={<EditDataset />} />
<OnboardingProvider>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OnboardingProvider renders WelcomeDialog, SpotlightOverlay and the floating launcher as siblings of children, so all three sit outside SingleTabGuard.

On a first visit in a second tab that wedges the app. The welcome dialog mounts at z-50, behind the guard's z-[9999] blocker, so it is invisible, while Radix's modal sets pointer-events: none on body and re-enables it only on the dialog content. The guard's "Use this tab" button is outside that content, so it stops responding and the app looks frozen with no visible cause. Escape does clear it, but Escape also writes dismissed, so that user never gets offered the tour again.

Repro: localStorage.removeItem("lelab:onboarding-v1"), close all LeLab tabs, open one, then open a second. The second tab shows "LeLab is already open in another tab" and the button does nothing.

Moving OnboardingProvider inside SingleTabGuard fixes it and keeps the ? button off a blocked tab as well.

<SingleTabGuard>
<TeleopStopNotice />
<UpdateNotice />
<Routes>
<Route path="/" element={<Landing />} />
<Route path="/teleoperation" element={<Teleoperation />} />
<Route path="/recording" element={<Recording />} />
<Route path="/upload" element={<Upload />} />
<Route path="/training" element={<Training />} />
<Route path="/training/:jobId" element={<Training />} />
<Route path="/inference" element={<Inference />} />
<Route path="/calibration" element={<Calibration />} />
<Route path="/edit-dataset" element={<EditDataset />} />

<Route path="*" element={<NotFound />} />
</Routes>
</SingleTabGuard>
<Toaster />
<Route path="*" element={<NotFound />} />
</Routes>
</SingleTabGuard>
<Toaster />
</OnboardingProvider>
</BrowserRouter>
</DragAndDropProvider>
</UrdfProvider>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/jobs/JobsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ const JobsSection: React.FC = () => {
untrackedHubInactive.length;

return (
<section className="space-y-6">
<section className="space-y-6" data-tour="jobs-section">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold text-white">Jobs</h2>
<div className="flex items-center gap-2">
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/landing/LandingTopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import HfAuthChip from "./HfAuthChip";
import TourLauncher from "@/components/onboarding/TourLauncher";

const LandingTopBar: React.FC = () => {
return (
Expand All @@ -15,7 +16,10 @@ const LandingTopBar: React.FC = () => {
LeLab
</span>
</div>
<HfAuthChip />
<div className="flex items-center gap-2">
<TourLauncher variant="inline" />
<HfAuthChip />
</div>
</div>
</header>
);
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/landing/RobotTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const RobotTile: React.FC<RobotTileProps> = ({
return (
<div className="bg-gray-800 rounded-lg border border-gray-700 p-3 flex flex-col gap-2 relative">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0" data-tour="robot-selector">
<RobotSelector
selectedName={selectedName}
availableNames={availableNames}
Expand Down Expand Up @@ -75,6 +75,7 @@ const RobotTile: React.FC<RobotTileProps> = ({
className="h-8 w-8 text-gray-300 hover:text-white"
onClick={() => onConfigure(robot.name)}
aria-label="Configure"
data-tour="robot-configure"
>
<Settings className="w-4 h-4" />
</Button>
Expand Down Expand Up @@ -102,7 +103,7 @@ const RobotTile: React.FC<RobotTileProps> = ({
{robot && (
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full">
<div className="w-full" data-tour="robot-teleop">
<Button
onClick={() => onTeleop(robot)}
disabled={teleopDisabled}
Expand Down
129 changes: 129 additions & 0 deletions frontend/src/components/onboarding/SpotlightOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React, { useEffect, useLayoutEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useLocation } from "react-router-dom";
import { useOnboarding } from "@/contexts/OnboardingContext";
import TourCard from "@/components/onboarding/TourCard";

// Padding around the highlighted element, in px.
const PAD = 8;
// How long to wait for a step's target to appear (e.g. after a route change)
// before falling back to a centered card so the tour never blocks.
const MOUNT_TIMEOUT_MS = 3000;

const SpotlightOverlay: React.FC = () => {
const { isActive, currentStep, stop } = useOnboarding();
const location = useLocation();
const [rect, setRect] = useState<DOMRect | null>(null);
// Becomes true once we've either located the target or given up (fallback).
const [ready, setReady] = useState(false);

const stepId = currentStep?.id ?? null;
const target = currentStep?.target ?? null;

// Locate the step's target, tracking it across scroll/resize/layout shifts.
// Re-runs whenever the step changes, and on route changes so a manual
// navigation away mid-step re-queries (rather than pinning a stale rect).
useLayoutEffect(() => {
setReady(false);
setRect(null);
if (!isActive || !stepId) return;

// Centered steps (welcome/done) have no target.
if (!target) {
setReady(true);
return;
}

let raf = 0;
let observer: ResizeObserver | null = null;
let tracked: Element | null = null;
const startedAt = performance.now();

const measure = () => {
if (!tracked) return;
// The element can leave the DOM under us (e.g. the user navigated away
// before this effect re-runs). Drop the spotlight instead of pinning it
// to a stale position.
if (!tracked.isConnected) {
setRect(null);
return;
}
setRect(tracked.getBoundingClientRect());
};

const attachTrackers = (el: Element) => {
tracked = el;
// Bring the target on-screen if it's below the fold (e.g. the cameras or
// jobs step). "nearest" scrolls the minimum needed and does nothing when
// it's already visible; the scroll listener below keeps the cutout and
// card glued to it as the smooth scroll settles.
el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
measure();
setReady(true);
window.addEventListener("scroll", measure, true);
window.addEventListener("resize", measure);
observer = new ResizeObserver(measure);
observer.observe(el);
};

const poll = () => {
const el = document.querySelector(`[data-tour="${target}"]`);
if (el) {
attachTrackers(el);
return;
}
if (performance.now() - startedAt > MOUNT_TIMEOUT_MS) {
// Target never mounted — show the copy centered rather than block.
setRect(null);
setReady(true);
return;
}
raf = requestAnimationFrame(poll);
};
poll();

return () => {
if (raf) cancelAnimationFrame(raf);
window.removeEventListener("scroll", measure, true);
window.removeEventListener("resize", measure);
observer?.disconnect();
};
}, [isActive, stepId, target, location.pathname]);

// Esc leaves the tour from anywhere.
useEffect(() => {
if (!isActive) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") stop(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [isActive, stop]);

if (!isActive || !currentStep || !ready) return null;

const hasRect = !!rect && rect.width > 0 && rect.height > 0;

return createPortal(
<div className="pointer-events-none fixed inset-0 z-40">
{hasRect ? (
<div
className="pointer-events-none absolute rounded-lg ring-2 ring-blue-400/80 transition-all duration-200"
style={{
top: rect!.top - PAD,
left: rect!.left - PAD,
width: rect!.width + PAD * 2,
height: rect!.height + PAD * 2,
boxShadow: "0 0 0 9999px rgba(0,0,0,0.6)",
}}
/>
) : (
<div className="absolute inset-0 bg-black/60" />
)}
<TourCard key={currentStep.id} rect={hasRect ? rect : null} />
</div>,
document.body
);
};

export default SpotlightOverlay;
Loading