feat: add guided tour component and integrate with Studio UI elements

This commit is contained in:
Shine1i 2026-02-15 19:20:37 +01:00
commit ea36112a05
7 changed files with 547 additions and 44 deletions

View file

@ -103,14 +103,15 @@ export function DatasetSection() {
);
return (
<SectionCard
icon={<HugeiconsIcon icon={Database02Icon} className="size-5" />}
title="Dataset"
description="Select or upload training data"
accent="indigo"
className="lg:col-span-4 min-h-[450px]"
>
<div className="flex flex-col gap-4">
<div data-tour="studio-dataset" className="lg:col-span-4">
<SectionCard
icon={<HugeiconsIcon icon={Database02Icon} className="size-5" />}
title="Dataset"
description="Select or upload training data"
accent="indigo"
className="min-h-[450px]"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Load from Hub
@ -316,12 +317,13 @@ export function DatasetSection() {
</Button>
</div>
</div>
</SectionCard>
<DatasetPreviewDialog
open={previewOpen}
onOpenChange={setPreviewOpen}
datasetName={dataset}
hfToken={hfToken}
/>
</SectionCard>
</div>
);
}

View file

@ -129,16 +129,17 @@ export function ModelSection() {
);
return (
<SectionCard
icon={<HugeiconsIcon icon={ChipIcon} className="size-5" />}
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"
>
<div className="grid gap-4 lg:grid-cols-4">
<div data-tour="studio-model" className="col-span-12">
<SectionCard
icon={<HugeiconsIcon icon={ChipIcon} className="size-5" />}
title="Model"
description="Select base model and training method"
accent="emerald"
featured={true}
badge="2x Faster Training"
className="shadow-border ring-1 ring-border"
>
<div className="grid gap-4 lg:grid-cols-4">
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Local Model
@ -355,7 +356,8 @@ export function ModelSection() {
/>
</InputGroup>
</div>
</div>
</SectionCard>
</div>
</SectionCard>
</div>
);
}

View file

@ -114,14 +114,15 @@ export function ParamsSection(): ReactElement {
const [hyperOpen, setHyperOpen] = useState(false);
return (
<SectionCard
icon={<HugeiconsIcon icon={Settings04Icon} className="size-5" />}
title="Parameters"
description="Configure training hyperparameters"
accent="orange"
className="lg:col-span-4 min-h-[450px]"
>
<div className="flex flex-col gap-4">
<div data-tour="studio-params" className="lg:col-span-4">
<SectionCard
icon={<HugeiconsIcon icon={Settings04Icon} className="size-5" />}
title="Parameters"
description="Configure training hyperparameters"
accent="orange"
className="min-h-[450px]"
>
<div className="flex flex-col gap-4">
{/* Epochs */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
@ -724,7 +725,8 @@ export function ParamsSection(): ReactElement {
</Tabs>
</CollapsibleContent>
</Collapsible>
</div>
</SectionCard>
</div>
</SectionCard>
</div>
);
}

View file

@ -40,14 +40,15 @@ export function TrainingSection() {
const [logOpen, setLogOpen] = useState(false);
return (
<SectionCard
icon={<HugeiconsIcon icon={ChartAverageIcon} className="size-5" />}
title="Training"
description="Monitor and control training"
accent="blue"
className="lg:col-span-4 min-h-[450px]"
>
<div className="flex flex-col gap-4">
<div data-tour="studio-training" className="lg:col-span-4">
<SectionCard
icon={<HugeiconsIcon icon={ChartAverageIcon} className="size-5" />}
title="Training"
description="Monitor and control training"
accent="blue"
className="min-h-[450px]"
>
<div className="flex flex-col gap-4">
{/* Loss chart */}
<div className="relative ">
<ChartContainer
@ -94,6 +95,7 @@ export function TrainingSection() {
{/* Start/Stop */}
<Button
data-tour="studio-start"
className="w-full cursor-pointer bg-gradient-to-r from-emerald-500 to-teal-500 text-white hover:from-emerald-600 hover:to-teal-600"
onClick={() => void startTrainingRun()}
disabled={isStarting}
@ -107,7 +109,12 @@ export function TrainingSection() {
{/* Save / Clear */}
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" className="cursor-pointer">
<Button
data-tour="studio-save"
variant="outline"
size="sm"
className="cursor-pointer"
>
<HugeiconsIcon icon={Archive04Icon} className="size-3.5" /> Save
Config
</Button>
@ -194,7 +201,8 @@ export function TrainingSection() {
)}
</CollapsibleContent>
</Collapsible>
</div>
</SectionCard>
</div>
</SectionCard>
</div>
);
}

View file

@ -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<TourStep[]>(
() => [
{
id: "header",
target: "studio-header",
title: "This page is a pipeline",
body: "Pick a base model, attach data, tune params, then launch training. Well 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 (
<div className="min-h-screen bg-background">
<main className="mx-auto max-w-7xl px-6 py-4">
<GuidedTour
open={tourOpen}
onOpenChange={setTourOpen}
steps={tourSteps}
onSkip={() => localStorage.setItem(STUDIO_TOUR_KEY, "skipped")}
onComplete={() => localStorage.setItem(STUDIO_TOUR_KEY, "done")}
/>
{canGoBack && (
<Button
variant="ghost"
@ -41,7 +102,7 @@ export function StudioPage(): ReactElement {
)}
{/* Header */}
<div className="mb-8 flex flex-col gap-0.5">
<div data-tour="studio-header" className="mb-8 flex flex-col gap-0.5">
<h1 className="text-2xl font-semibold tracking-tight">
Fine-tuning Studio
</h1>

View file

@ -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="<target>"
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 (
<svg
className="absolute inset-0 size-full"
viewBox={`0 0 ${vw} ${vh}`}
preserveAspectRatio="none"
aria-hidden={true}
>
<defs>
<radialGradient id={`${maskId}-v`} cx="50%" cy="45%" r="80%">
<stop offset="0%" stopColor="rgba(6, 9, 15, 0.35)" />
<stop offset="55%" stopColor="rgba(6, 9, 15, 0.65)" />
<stop offset="100%" stopColor="rgba(6, 9, 15, 0.88)" />
</radialGradient>
<mask id={maskId}>
<rect x="0" y="0" width={vw} height={vh} fill="white" />
<motion.rect
x={hole.x}
y={hole.y}
width={hole.w}
height={hole.h}
rx={r}
fill="black"
transition={{ type: "spring", stiffness: 260, damping: 30 }}
/>
</mask>
</defs>
<rect
x="0"
y="0"
width={vw}
height={vh}
fill={`url(#${maskId}-v)`}
mask={`url(#${maskId})`}
/>
</svg>
);
}
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<Rect | null>(null);
const [placement, setPlacement] = useState<Placement>("right");
const [cardPos, setCardPos] = useState<{ left: number; top: number }>({
left: 12,
top: 12,
});
const cardRef = useRef<HTMLDivElement>(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 (
<DialogPrimitive.Root
open={open}
onOpenChange={(v) => {
if (v) onOpenChange(true);
else requestClose("skip");
}}
modal={true}
>
<DialogPrimitive.Portal>
<AnimatePresence>
{open && (
<>
<DialogPrimitive.Overlay asChild>
<motion.div
className="fixed inset-0 z-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
<SpotlightOverlay rect={spotlightRect} vw={vw} vh={vh} maskId={maskId} />
{spotlightRect && (
<motion.div
className="fixed z-[51] pointer-events-none rounded-[22px] ring-1 ring-white/10"
initial={false}
animate={{
left: spotlightRect.x,
top: spotlightRect.y,
width: spotlightRect.w,
height: spotlightRect.h,
boxShadow:
"0 0 0 1px rgba(34, 211, 238, 0.12), 0 0 0 6px rgba(16, 185, 129, 0.08), 0 18px 90px rgba(0,0,0,0.55)",
}}
transition={{ type: "spring", stiffness: 260, damping: 30 }}
/>
)}
</motion.div>
</DialogPrimitive.Overlay>
<DialogPrimitive.Content
onPointerDownOutside={(e) => 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,
}}
>
<motion.div
ref={cardRef}
initial={{ opacity: 0, scale: 0.985, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.99, y: 10 }}
transition={{ duration: 0.22, ease: [0.165, 0.84, 0.44, 1] }}
className={cn(
"relative overflow-hidden rounded-[28px] corner-squircle",
"bg-[#0b0f16]/95 text-white ring-1 ring-white/10",
"shadow-[0_30px_120px_rgba(0,0,0,0.65)]",
)}
style={{
fontFamily: "'Figtree Variable', ui-sans-serif, sans-serif",
}}
>
<div
className={cn(
"absolute z-10 size-3 rotate-45 rounded-[3px] bg-[#0b0f16]/95 ring-1 ring-white/10",
placement === "right" &&
"-left-1 top-1/2 -translate-y-1/2",
placement === "left" &&
"-right-1 top-1/2 -translate-y-1/2",
placement === "bottom" &&
"left-1/2 -top-1 -translate-x-1/2",
placement === "top" &&
"left-1/2 -bottom-1 -translate-x-1/2",
)}
aria-hidden={true}
/>
<div className="absolute inset-x-0 top-0 h-20 bg-gradient-to-b from-emerald-400/18 via-cyan-300/6 to-transparent" />
<div className="absolute -left-14 -top-16 size-44 rounded-full bg-emerald-400/10 blur-2xl" />
<div className="absolute -right-14 -bottom-16 size-44 rounded-full bg-cyan-300/10 blur-2xl" />
<div className="relative p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="inline-flex items-center gap-2 rounded-full bg-white/6 px-2.5 py-1 text-[10px] font-mono text-white/70 ring-1 ring-white/10">
{idx + 1}/{total}
<span className="size-1 rounded-full bg-emerald-300/70" />
guided tour
</div>
<DialogPrimitive.Title
className="mt-2 text-[18px] leading-tight"
style={{ fontFamily: "var(--font-serif)" }}
>
{step?.title ?? "Quick tour"}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="mt-1.5 text-sm leading-relaxed text-white/72">
{step?.body ?? "Lets get you oriented."}
</DialogPrimitive.Description>
</div>
<Button
variant="ghost"
size="icon-sm"
className="text-white/70 hover:text-white hover:bg-white/8"
onClick={() => requestClose("skip")}
aria-label="Skip tour"
>
<HugeiconsIcon icon={Cancel01Icon} className="size-4" />
</Button>
</div>
<div className="mt-5 flex items-center justify-between gap-3">
<Button
variant="ghost"
className="text-white/70 hover:text-white hover:bg-white/8"
onClick={() => requestClose("skip")}
>
Skip
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="border-white/12 bg-white/6 text-white hover:bg-white/10 hover:text-white"
disabled={idx === 0}
onClick={() => setIdx((i) => Math.max(0, i - 1))}
>
<HugeiconsIcon icon={ArrowLeft01Icon} className="size-4" />
Back
</Button>
{isLast ? (
<Button
variant="dark"
className="bg-gradient-to-r from-emerald-300 to-cyan-200 text-black hover:from-emerald-200 hover:to-cyan-100"
onClick={() => requestClose("complete")}
>
<HugeiconsIcon icon={CheckmarkCircle01Icon} className="size-4" />
Done
</Button>
) : (
<Button
variant="dark"
className="bg-gradient-to-r from-emerald-300 to-cyan-200 text-black hover:from-emerald-200 hover:to-cyan-100"
onClick={() => setIdx((i) => Math.min(total - 1, i + 1))}
>
Next
<HugeiconsIcon icon={ArrowRight01Icon} className="size-4" />
</Button>
)}
</div>
</div>
</div>
<div className="h-px bg-gradient-to-r from-transparent via-white/12 to-transparent" />
<div className="px-5 py-3 text-[11px] text-white/55">
Tip: `Esc` skips. Tour blocks clicks so you can read.
</div>
</motion.div>
</DialogPrimitive.Content>
</>
)}
</AnimatePresence>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View file

@ -0,0 +1,3 @@
export { GuidedTour } from "./guided-tour";
export type { TourStep } from "./guided-tour";