diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx
index 0d325fbefd..ba839f2e1a 100644
--- a/studio/frontend/src/features/studio/sections/dataset-section.tsx
+++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx
@@ -103,14 +103,15 @@ export function DatasetSection() {
);
return (
- }
- title="Dataset"
- description="Select or upload training data"
- accent="indigo"
- className="lg:col-span-4 min-h-[450px]"
- >
-
+
+
}
+ title="Dataset"
+ description="Select or upload training data"
+ accent="indigo"
+ className="min-h-[450px]"
+ >
+
Load from Hub
@@ -316,12 +317,13 @@ export function DatasetSection() {
+
-
+
);
}
diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx
index fcaa319e1d..b21d433fb0 100644
--- a/studio/frontend/src/features/studio/sections/model-section.tsx
+++ b/studio/frontend/src/features/studio/sections/model-section.tsx
@@ -129,16 +129,17 @@ export function ModelSection() {
);
return (
-
}
- title="Model"
- description="Select base model and training method"
- accent="emerald"
- featured={true}
- badge="2x Faster Training"
- className="col-span-12 shadow-border ring-1 ring-border"
- >
-
+
+
}
+ title="Model"
+ description="Select base model and training method"
+ accent="emerald"
+ featured={true}
+ badge="2x Faster Training"
+ className="shadow-border ring-1 ring-border"
+ >
+
Local Model
@@ -355,7 +356,8 @@ export function ModelSection() {
/>
-
-
+
+
+
);
}
diff --git a/studio/frontend/src/features/studio/sections/params-section.tsx b/studio/frontend/src/features/studio/sections/params-section.tsx
index 8c753cde7e..415aca3fac 100644
--- a/studio/frontend/src/features/studio/sections/params-section.tsx
+++ b/studio/frontend/src/features/studio/sections/params-section.tsx
@@ -114,14 +114,15 @@ export function ParamsSection(): ReactElement {
const [hyperOpen, setHyperOpen] = useState(false);
return (
-
}
- title="Parameters"
- description="Configure training hyperparameters"
- accent="orange"
- className="lg:col-span-4 min-h-[450px]"
- >
-
+
+
}
+ title="Parameters"
+ description="Configure training hyperparameters"
+ accent="orange"
+ className="min-h-[450px]"
+ >
+
{/* Epochs */}
@@ -724,7 +725,8 @@ export function ParamsSection(): ReactElement {
-
-
+
+
+
);
}
diff --git a/studio/frontend/src/features/studio/sections/training-section.tsx b/studio/frontend/src/features/studio/sections/training-section.tsx
index fa5c4e85f5..a5f4cba24e 100644
--- a/studio/frontend/src/features/studio/sections/training-section.tsx
+++ b/studio/frontend/src/features/studio/sections/training-section.tsx
@@ -40,14 +40,15 @@ export function TrainingSection() {
const [logOpen, setLogOpen] = useState(false);
return (
-
}
- title="Training"
- description="Monitor and control training"
- accent="blue"
- className="lg:col-span-4 min-h-[450px]"
- >
-
+
+
}
+ title="Training"
+ description="Monitor and control training"
+ accent="blue"
+ className="min-h-[450px]"
+ >
+
{/* Loss chart */}
void startTrainingRun()}
disabled={isStarting}
@@ -107,7 +109,12 @@ export function TrainingSection() {
{/* Save / Clear */}
-
-
+
+
+
);
}
diff --git a/studio/frontend/src/features/studio/studio-page.tsx b/studio/frontend/src/features/studio/studio-page.tsx
index 5811f161db..61654f4954 100644
--- a/studio/frontend/src/features/studio/studio-page.tsx
+++ b/studio/frontend/src/features/studio/studio-page.tsx
@@ -5,15 +5,18 @@ import {
useTrainingRuntimeLifecycle,
useTrainingRuntimeStore,
} from "@/features/training";
+import { GuidedTour, type TourStep } from "@/features/tour";
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import type { ReactElement } from "react";
+import { type ReactElement, useEffect, useMemo, useState } from "react";
import { DatasetSection } from "./sections/dataset-section";
import { ModelSection } from "./sections/model-section";
import { ParamsSection } from "./sections/params-section";
import { TrainingSection } from "./sections/training-section";
import { TrainingView } from "./training-view";
+const STUDIO_TOUR_KEY = "tour:studio:v1";
+
export function StudioPage(): ReactElement {
useTrainingRuntimeLifecycle();
const showTrainingView = useTrainingRuntimeStore(shouldShowTrainingView);
@@ -24,10 +27,68 @@ export function StudioPage(): ReactElement {
const { dismissTrainingRun } = useTrainingActions();
const canGoBack = runtimePhase === "stopped" || runtimePhase === "error";
+ const tourEnabled = hasHydratedRuntime && !isHydratingRuntime && !showTrainingView;
+ const [tourOpen, setTourOpen] = useState(false);
+
+ const tourSteps = useMemo
(
+ () => [
+ {
+ id: "header",
+ target: "studio-header",
+ title: "This page is a pipeline",
+ body: "Pick a base model, attach data, tune params, then launch training. We’ll hit the 5 things that matter.",
+ },
+ {
+ id: "model",
+ target: "studio-model",
+ title: "Choose model + method",
+ body: "Base model sets your ceiling. Method sets speed vs quality. Start here, then everything else follows.",
+ },
+ {
+ id: "dataset",
+ target: "studio-dataset",
+ title: "Bring a dataset",
+ body: "Search Hub or paste `user/dataset`. Preview a few rows before you commit hours of compute.",
+ },
+ {
+ id: "params",
+ target: "studio-params",
+ title: "Dial hyperparams",
+ body: "Epochs + context length + LR. Keep it boring: small changes, one at a time.",
+ },
+ {
+ id: "start",
+ target: "studio-start",
+ title: "Start training",
+ body: "One click. If it fails, the error text is the first place to look (token, path, config).",
+ },
+ {
+ id: "save",
+ target: "studio-save",
+ title: "Save config",
+ body: "Save good runs. Repeatability beats vibe. You can iterate from a known baseline.",
+ },
+ ],
+ [],
+ );
+
+ useEffect(() => {
+ if (!tourEnabled) return;
+ if (localStorage.getItem(STUDIO_TOUR_KEY)) return;
+ setTourOpen(true);
+ }, [tourEnabled]);
return (
+ localStorage.setItem(STUDIO_TOUR_KEY, "skipped")}
+ onComplete={() => localStorage.setItem(STUDIO_TOUR_KEY, "done")}
+ />
+
{canGoBack && (
+
Fine-tuning Studio
diff --git a/studio/frontend/src/features/tour/guided-tour.tsx b/studio/frontend/src/features/tour/guided-tour.tsx
new file mode 100644
index 0000000000..6fabe80d26
--- /dev/null
+++ b/studio/frontend/src/features/tour/guided-tour.tsx
@@ -0,0 +1,425 @@
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowLeft01Icon,
+ ArrowRight01Icon,
+ Cancel01Icon,
+ CheckmarkCircle01Icon,
+} from "@hugeicons/core-free-icons";
+import { Dialog as DialogPrimitive } from "radix-ui";
+import { AnimatePresence, motion } from "motion/react";
+import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
+
+export type TourStep = {
+ id: string;
+ target: string; // data-tour="
"
+ title: string;
+ body: string;
+};
+
+type Rect = { x: number; y: number; w: number; h: number };
+type Placement = "right" | "left" | "top" | "bottom";
+
+function clamp(n: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, n));
+}
+
+function cssEscape(value: string) {
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
+ return CSS.escape(value);
+ }
+ return value.replace(/"/g, '\\"');
+}
+
+function toRect(domRect: DOMRect): Rect {
+ return { x: domRect.left, y: domRect.top, w: domRect.width, h: domRect.height };
+}
+
+function padded(r: Rect, pad: number, vw: number, vh: number): Rect {
+ const x = clamp(r.x - pad, 8, vw - 8);
+ const y = clamp(r.y - pad, 8, vh - 8);
+ const w = clamp(r.w + pad * 2, 24, vw - x - 8);
+ const h = clamp(r.h + pad * 2, 24, vh - y - 8);
+ return { x, y, w, h };
+}
+
+function pickPlacement(
+ target: Rect,
+ card: { w: number; h: number },
+ vw: number,
+ vh: number,
+ gap: number,
+): Placement {
+ const canRight = target.x + target.w + gap + card.w <= vw - 12;
+ const canLeft = target.x - gap - card.w >= 12;
+ const canBottom = target.y + target.h + gap + card.h <= vh - 12;
+ const canTop = target.y - gap - card.h >= 12;
+
+ if (canRight) return "right";
+ if (canLeft) return "left";
+ if (canBottom) return "bottom";
+ if (canTop) return "top";
+ return "bottom";
+}
+
+function computeCardPos(
+ placement: Placement,
+ target: Rect,
+ card: { w: number; h: number },
+ vw: number,
+ vh: number,
+ gap: number,
+) {
+ let left = 12;
+ let top = 12;
+
+ if (placement === "right") {
+ left = target.x + target.w + gap;
+ top = target.y + target.h / 2 - card.h / 2;
+ }
+ if (placement === "left") {
+ left = target.x - gap - card.w;
+ top = target.y + target.h / 2 - card.h / 2;
+ }
+ if (placement === "bottom") {
+ left = target.x + target.w / 2 - card.w / 2;
+ top = target.y + target.h + gap;
+ }
+ if (placement === "top") {
+ left = target.x + target.w / 2 - card.w / 2;
+ top = target.y - gap - card.h;
+ }
+
+ left = clamp(left, 12, vw - card.w - 12);
+ top = clamp(top, 12, vh - card.h - 12);
+ return { left, top };
+}
+
+function SpotlightOverlay({
+ rect,
+ vw,
+ vh,
+ maskId,
+}: {
+ rect: Rect | null;
+ vw: number;
+ vh: number;
+ maskId: string;
+}) {
+ const hole = rect ?? { x: vw / 2 - 140, y: vh / 2 - 90, w: 280, h: 180 };
+ const r = 22;
+
+ return (
+
+ );
+}
+
+export function GuidedTour({
+ open,
+ onOpenChange,
+ steps,
+ onSkip,
+ onComplete,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ steps: TourStep[];
+ onSkip: () => void;
+ onComplete: () => void;
+}) {
+ const maskId = `${useId()}-tour-mask`;
+ const [idx, setIdx] = useState(0);
+ const [vw, setVw] = useState(0);
+ const [vh, setVh] = useState(0);
+ const [targetRect, setTargetRect] = useState(null);
+ const [placement, setPlacement] = useState("right");
+ const [cardPos, setCardPos] = useState<{ left: number; top: number }>({
+ left: 12,
+ top: 12,
+ });
+ const cardRef = useRef(null);
+ const closeLockRef = useRef(false);
+
+ const step = steps[idx] ?? null;
+ const total = steps.length;
+ const isLast = idx === total - 1;
+
+ const spotlightRect = useMemo(() => {
+ if (!targetRect || !vw || !vh) return null;
+ return padded(targetRect, 14, vw, vh);
+ }, [targetRect, vw, vh]);
+
+ useEffect(() => {
+ if (!open) return;
+ setIdx(0);
+ closeLockRef.current = false;
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+ function onResize() {
+ setVw(window.innerWidth);
+ setVh(window.innerHeight);
+ }
+ onResize();
+ window.addEventListener("resize", onResize);
+ return () => window.removeEventListener("resize", onResize);
+ }, [open]);
+
+ useEffect(() => {
+ if (!open || !step) return;
+
+ const sel = `[data-tour="${cssEscape(step.target)}"]`;
+ const el = document.querySelector(sel) as HTMLElement | null;
+ if (!el) {
+ setTargetRect(null);
+ return;
+ }
+
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "smooth" });
+
+ let raf = 0;
+ let t = 0;
+ const update = () => {
+ const r = el.getBoundingClientRect();
+ setTargetRect(toRect(r));
+ };
+
+ raf = window.requestAnimationFrame(update);
+ t = window.setTimeout(update, 240);
+
+ const ro = new ResizeObserver(() => update());
+ ro.observe(el);
+ window.addEventListener("scroll", update, { capture: true, passive: true });
+
+ return () => {
+ window.cancelAnimationFrame(raf);
+ window.clearTimeout(t);
+ ro.disconnect();
+ window.removeEventListener("scroll", update, true);
+ };
+ }, [open, step]);
+
+ useLayoutEffect(() => {
+ if (!open || !spotlightRect || !vw || !vh) return;
+ const card = cardRef.current?.getBoundingClientRect();
+ if (!card) return;
+
+ const gap = 14;
+ const picked = pickPlacement(spotlightRect, { w: card.width, h: card.height }, vw, vh, gap);
+ setPlacement(picked);
+ setCardPos(computeCardPos(picked, spotlightRect, { w: card.width, h: card.height }, vw, vh, gap));
+ }, [open, spotlightRect, vw, vh, idx]);
+
+ function requestClose(reason: "skip" | "complete") {
+ if (closeLockRef.current) return;
+ closeLockRef.current = true;
+ if (reason === "skip") onSkip();
+ else onComplete();
+ onOpenChange(false);
+ }
+
+ return (
+ {
+ if (v) onOpenChange(true);
+ else requestClose("skip");
+ }}
+ modal={true}
+ >
+
+
+ {open && (
+ <>
+
+
+
+ {spotlightRect && (
+
+ )}
+
+
+
+ e.preventDefault()}
+ onInteractOutside={(e) => e.preventDefault()}
+ className={cn(
+ "fixed z-[52] outline-none",
+ "w-[min(420px,calc(100vw-1.5rem))]",
+ )}
+ style={{
+ left: cardPos.left,
+ top: cardPos.top,
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {idx + 1}/{total}
+
+ guided tour
+
+
+ {step?.title ?? "Quick tour"}
+
+
+ {step?.body ?? "Let’s get you oriented."}
+
+
+
+
requestClose("skip")}
+ aria-label="Skip tour"
+ >
+
+
+
+
+
+
requestClose("skip")}
+ >
+ Skip
+
+
+
+ setIdx((i) => Math.max(0, i - 1))}
+ >
+
+ Back
+
+ {isLast ? (
+ requestClose("complete")}
+ >
+
+ Done
+
+ ) : (
+ setIdx((i) => Math.min(total - 1, i + 1))}
+ >
+ Next
+
+
+ )}
+
+
+
+
+
+
+ Tip: `Esc` skips. Tour blocks clicks so you can read.
+
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/studio/frontend/src/features/tour/index.ts b/studio/frontend/src/features/tour/index.ts
new file mode 100644
index 0000000000..750edb3f4b
--- /dev/null
+++ b/studio/frontend/src/features/tour/index.ts
@@ -0,0 +1,3 @@
+export { GuidedTour } from "./guided-tour";
+export type { TourStep } from "./guided-tour";
+