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>
<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