Merge pull request #101 from unslothai/feature/guided-tour
feat: studio: guided tour (1st visit, skippable)
This commit is contained in:
commit
ed583f2b78
23 changed files with 798 additions and 49 deletions
|
|
@ -63,7 +63,10 @@ export function Navbar() {
|
|||
</div>
|
||||
|
||||
{/* Center: pill nav */}
|
||||
<nav className="flex items-center rounded-full border border-border bg-card p-1 ring-1 ring-foreground/5">
|
||||
<nav
|
||||
data-tour="navbar"
|
||||
className="flex items-center rounded-full border border-border bg-card p-1 ring-1 ring-foreground/5"
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
if (!item.enabled) {
|
||||
|
|
|
|||
|
|
@ -121,14 +121,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
|
||||
|
|
@ -347,6 +348,7 @@ export function DatasetSection() {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<DatasetPreviewDialog
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
|
|
@ -355,6 +357,6 @@ export function DatasetSection() {
|
|||
datasetSubset={datasetSubset}
|
||||
datasetSplit={datasetSplit}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,19 +169,20 @@ 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 className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Local Model
|
||||
<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 data-tour="studio-local-model" className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Local Model
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<button
|
||||
|
|
@ -207,7 +208,7 @@ export function ModelSection() {
|
|||
</InputGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div data-tour="studio-base-model" className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Base Model
|
||||
<Tooltip>
|
||||
|
|
@ -332,7 +333,7 @@ export function ModelSection() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div data-tour="studio-method" className="flex flex-col gap-2">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
Method
|
||||
<Tooltip>
|
||||
|
|
@ -416,7 +417,8 @@ export function ModelSection() {
|
|||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,19 @@ import {
|
|||
useTrainingRuntimeLifecycle,
|
||||
useTrainingRuntimeStore,
|
||||
} from "@/features/training";
|
||||
import { GuidedTour } from "@/features/tour";
|
||||
import { studioTourSteps } from "@/features/studio/tour";
|
||||
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { type ReactElement, useEffect, 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 +28,26 @@ export function StudioPage(): ReactElement {
|
|||
const { dismissTrainingRun } = useTrainingActions();
|
||||
|
||||
const canGoBack = runtimePhase === "stopped" || runtimePhase === "error";
|
||||
const tourEnabled = hasHydratedRuntime && !isHydratingRuntime && !showTrainingView;
|
||||
const [tourOpen, setTourOpen] = useState(false);
|
||||
|
||||
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={studioTourSteps}
|
||||
onSkip={() => localStorage.setItem(STUDIO_TOUR_KEY, "skipped")}
|
||||
onComplete={() => localStorage.setItem(STUDIO_TOUR_KEY, "done")}
|
||||
/>
|
||||
|
||||
{canGoBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
2
studio/frontend/src/features/studio/tour/index.ts
Normal file
2
studio/frontend/src/features/studio/tour/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { studioTourSteps } from "./steps";
|
||||
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { ReadMore, type TourStep } from "@/features/tour";
|
||||
|
||||
export const studioBaseModelStep: TourStep = {
|
||||
id: "base-model",
|
||||
target: "studio-base-model",
|
||||
title: "Base model from Hugging Face",
|
||||
body: (
|
||||
<>
|
||||
Search Hub here. Paste <span className="font-mono">org/model</span> too.
|
||||
Pick something close to your domain to save compute. <ReadMore />
|
||||
</>
|
||||
),
|
||||
};
|
||||
13
studio/frontend/src/features/studio/tour/steps/dataset.tsx
Normal file
13
studio/frontend/src/features/studio/tour/steps/dataset.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { ReadMore, type TourStep } from "@/features/tour";
|
||||
|
||||
export const studioDatasetStep: TourStep = {
|
||||
id: "dataset",
|
||||
target: "studio-dataset",
|
||||
title: "Dataset",
|
||||
body: (
|
||||
<>
|
||||
Search Hub or paste <span className="font-mono">user/dataset</span>.
|
||||
Preview a few rows before you burn hours of compute. <ReadMore />
|
||||
</>
|
||||
),
|
||||
};
|
||||
21
studio/frontend/src/features/studio/tour/steps/index.tsx
Normal file
21
studio/frontend/src/features/studio/tour/steps/index.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { TourStep } from "@/features/tour";
|
||||
import { studioBaseModelStep } from "./base-model";
|
||||
import { studioDatasetStep } from "./dataset";
|
||||
import { studioLocalModelStep } from "./local-model";
|
||||
import { studioMethodStep } from "./method";
|
||||
import { studioNavStep } from "./nav";
|
||||
import { studioParamsStep } from "./params";
|
||||
import { studioSaveStep } from "./save";
|
||||
import { studioStartStep } from "./start";
|
||||
|
||||
export const studioTourSteps: TourStep[] = [
|
||||
studioNavStep,
|
||||
studioLocalModelStep,
|
||||
studioBaseModelStep,
|
||||
studioMethodStep,
|
||||
studioDatasetStep,
|
||||
studioParamsStep,
|
||||
studioStartStep,
|
||||
studioSaveStep,
|
||||
];
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { ReadMore, type TourStep } from "@/features/tour";
|
||||
|
||||
export const studioLocalModelStep: TourStep = {
|
||||
id: "local-model",
|
||||
target: "studio-local-model",
|
||||
title: "Local model path",
|
||||
body: (
|
||||
<>
|
||||
Point to a local folder (<span className="font-mono">./models/...</span>)
|
||||
or a custom HF repo. Use this when you already downloaded weights.{" "}
|
||||
<ReadMore />
|
||||
</>
|
||||
),
|
||||
};
|
||||
13
studio/frontend/src/features/studio/tour/steps/method.tsx
Normal file
13
studio/frontend/src/features/studio/tour/steps/method.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { ReadMore, type TourStep } from "@/features/tour";
|
||||
|
||||
export const studioMethodStep: TourStep = {
|
||||
id: "method",
|
||||
target: "studio-method",
|
||||
title: "Method: QLoRA vs LoRA vs Full",
|
||||
body: (
|
||||
<>
|
||||
QLoRA: lowest VRAM (4-bit). LoRA: fast + solid (16-bit adapters). Full:
|
||||
slowest, highest cost, updates all weights. <ReadMore />
|
||||
</>
|
||||
),
|
||||
};
|
||||
14
studio/frontend/src/features/studio/tour/steps/nav.tsx
Normal file
14
studio/frontend/src/features/studio/tour/steps/nav.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { TourStep } from "@/features/tour";
|
||||
|
||||
export const studioNavStep: TourStep = {
|
||||
id: "nav",
|
||||
target: "navbar",
|
||||
title: "Quick orientation",
|
||||
body: (
|
||||
<>
|
||||
Studio is where you fine-tune. Export ships results. Chat is for poking at
|
||||
models. This tour is Studio-only (for now).
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
13
studio/frontend/src/features/studio/tour/steps/params.tsx
Normal file
13
studio/frontend/src/features/studio/tour/steps/params.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { ReadMore, type TourStep } from "@/features/tour";
|
||||
|
||||
export const studioParamsStep: TourStep = {
|
||||
id: "params",
|
||||
target: "studio-params",
|
||||
title: "Dial hyperparams",
|
||||
body: (
|
||||
<>
|
||||
Epochs + context length + LR. Keep it boring: small changes, one at a
|
||||
time. <ReadMore />
|
||||
</>
|
||||
),
|
||||
};
|
||||
14
studio/frontend/src/features/studio/tour/steps/save.tsx
Normal file
14
studio/frontend/src/features/studio/tour/steps/save.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { TourStep } from "@/features/tour";
|
||||
|
||||
export const studioSaveStep: TourStep = {
|
||||
id: "save",
|
||||
target: "studio-save",
|
||||
title: "Save config",
|
||||
body: (
|
||||
<>
|
||||
Save good runs. Repeatability beats vibe. You can iterate from a known
|
||||
baseline.
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
14
studio/frontend/src/features/studio/tour/steps/start.tsx
Normal file
14
studio/frontend/src/features/studio/tour/steps/start.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { TourStep } from "@/features/tour";
|
||||
|
||||
export const studioStartStep: TourStep = {
|
||||
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).
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
409
studio/frontend/src/features/tour/components/guided-tour.tsx
Normal file
409
studio/frontend/src/features/tour/components/guided-tour.tsx
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
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";
|
||||
import { cssEscape, toRect } from "../lib/dom";
|
||||
import { fireConfettiFireworks } from "../lib/confetti-fireworks";
|
||||
import { computeCardPos, padded, pickPlacement } from "../lib/layout";
|
||||
import type { Placement, Rect, TourStep } from "../types";
|
||||
|
||||
// (types + layout/dom helpers live in ../types and ../lib)
|
||||
|
||||
type SpotlightOverlayProps = {
|
||||
rect: Rect | null;
|
||||
vw: number;
|
||||
vh: number;
|
||||
maskId: string;
|
||||
};
|
||||
|
||||
function SpotlightOverlay({
|
||||
rect,
|
||||
vw,
|
||||
vh,
|
||||
maskId,
|
||||
}: SpotlightOverlayProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
type GuidedTourProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
steps: TourStep[];
|
||||
onSkip: () => void;
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
export function GuidedTour({
|
||||
open,
|
||||
onOpenChange,
|
||||
steps,
|
||||
onSkip,
|
||||
onComplete,
|
||||
}: GuidedTourProps) {
|
||||
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 rafRef = useRef<number | null>(null);
|
||||
const lastRectRef = useRef<Rect | null>(null);
|
||||
|
||||
const step = steps[idx] ?? null;
|
||||
const total = steps.length;
|
||||
const isLast = idx === total - 1;
|
||||
|
||||
const spotlightRect = useMemo(() => {
|
||||
if (!targetRect || !vw || !vh) return null;
|
||||
const pad = step?.target === "navbar" ? 4 : 14;
|
||||
return padded(targetRect, pad, vw, vh);
|
||||
}, [step?.target, targetRect, vw, vh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setIdx(0);
|
||||
setTargetRect(null);
|
||||
closeLockRef.current = false;
|
||||
lastRectRef.current = null;
|
||||
}, [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 found = document.querySelector(sel);
|
||||
if (!(found instanceof HTMLElement)) {
|
||||
setTargetRect(null);
|
||||
return;
|
||||
}
|
||||
const el = found;
|
||||
|
||||
if (step.target !== "navbar") {
|
||||
el.scrollIntoView({
|
||||
block: "center",
|
||||
inline: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
let raf = 0;
|
||||
let t = 0;
|
||||
|
||||
function rectChanged(a: Rect | null, b: Rect): boolean {
|
||||
if (!a) return true;
|
||||
return (
|
||||
Math.abs(a.x - b.x) > 0.5 ||
|
||||
Math.abs(a.y - b.y) > 0.5 ||
|
||||
Math.abs(a.w - b.w) > 0.5 ||
|
||||
Math.abs(a.h - b.h) > 0.5
|
||||
);
|
||||
}
|
||||
|
||||
function read() {
|
||||
const r = el.getBoundingClientRect();
|
||||
const next = toRect(r);
|
||||
const prev = lastRectRef.current;
|
||||
if (rectChanged(prev, next)) {
|
||||
lastRectRef.current = next;
|
||||
setTargetRect(next);
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (rafRef.current != null) return;
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
read();
|
||||
});
|
||||
}
|
||||
|
||||
raf = window.requestAnimationFrame(read);
|
||||
t = window.setTimeout(schedule, 240);
|
||||
|
||||
const ro = new ResizeObserver(() => schedule());
|
||||
ro.observe(el);
|
||||
window.addEventListener("scroll", schedule, { capture: true, passive: true });
|
||||
window.addEventListener("resize", schedule, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
window.clearTimeout(t);
|
||||
ro.disconnect();
|
||||
window.removeEventListener("scroll", schedule, true);
|
||||
window.removeEventListener("resize", schedule);
|
||||
if (rafRef.current != null) {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [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;
|
||||
void fireConfettiFireworks();
|
||||
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-white/95 text-foreground ring-1 ring-black/10",
|
||||
"shadow-[0_30px_120px_rgba(0,0,0,0.35)]",
|
||||
)}
|
||||
style={{
|
||||
fontFamily: "'Figtree Variable', ui-sans-serif, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-10 size-3 rotate-45 rounded-[3px] bg-white/95 ring-1 ring-black/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/20 blur-2xl" />
|
||||
<div className="absolute -right-14 -bottom-16 size-44 rounded-full bg-cyan-300/18 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-black/[0.04] px-2.5 py-1 text-[10px] font-mono text-foreground/60 ring-1 ring-black/10">
|
||||
{idx + 1}/{total}
|
||||
<span className="size-1 rounded-full bg-emerald-500/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-foreground/70">
|
||||
{step?.body ?? "Let’s get you oriented."}
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-foreground/60 hover:text-foreground hover:bg-black/[0.05]"
|
||||
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-foreground/60 hover:text-foreground hover:bg-black/[0.05]"
|
||||
onClick={() => requestClose("skip")}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-black/10 bg-white/70 text-foreground hover:bg-white hover:text-foreground"
|
||||
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-500 to-cyan-400 text-white hover:from-emerald-600 hover:to-cyan-500"
|
||||
onClick={() => requestClose("complete")}
|
||||
>
|
||||
<HugeiconsIcon icon={CheckmarkCircle01Icon} className="size-4" />
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="dark"
|
||||
className="bg-gradient-to-r from-emerald-500 to-cyan-400 text-white hover:from-emerald-600 hover:to-cyan-500"
|
||||
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-black/10 to-transparent" />
|
||||
<div className="px-5 py-3 text-[11px] text-foreground/55">
|
||||
Tip: `Esc` skips. Tour blocks clicks so you can read.
|
||||
</div>
|
||||
</motion.div>
|
||||
</DialogPrimitive.Content>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
13
studio/frontend/src/features/tour/components/read-more.tsx
Normal file
13
studio/frontend/src/features/tour/components/read-more.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export function ReadMore({ href = "#" }: { href?: string }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
if (href === "#") e.preventDefault();
|
||||
}}
|
||||
className="text-emerald-600 underline underline-offset-2 hover:text-emerald-700"
|
||||
>
|
||||
Read more
|
||||
</a>
|
||||
);
|
||||
}
|
||||
3
studio/frontend/src/features/tour/index.ts
Normal file
3
studio/frontend/src/features/tour/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { GuidedTour } from "./components/guided-tour";
|
||||
export { ReadMore } from "./components/read-more";
|
||||
export type { TourStep } from "./types";
|
||||
59
studio/frontend/src/features/tour/lib/confetti-fireworks.ts
Normal file
59
studio/frontend/src/features/tour/lib/confetti-fireworks.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
type FireworksOpts = {
|
||||
durationMs?: number;
|
||||
intervalMs?: number;
|
||||
zIndex?: number;
|
||||
};
|
||||
|
||||
export async function fireConfettiFireworks(opts: FireworksOpts = {}) {
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const prefersReduce =
|
||||
window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
|
||||
if (prefersReduce) return;
|
||||
|
||||
const confetti = (await import("canvas-confetti")).default;
|
||||
|
||||
const duration = opts.durationMs ?? 1200;
|
||||
const intervalMs = opts.intervalMs ?? 240;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const defaults = {
|
||||
startVelocity: 28,
|
||||
spread: 360,
|
||||
ticks: 58,
|
||||
zIndex: opts.zIndex ?? 99999,
|
||||
disableForReducedMotion: true,
|
||||
} as const;
|
||||
|
||||
const randomInRange = (min: number, max: number) =>
|
||||
Math.random() * (max - min) + min;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
if (timeLeft <= 0) {
|
||||
window.clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
const particleCount = Math.max(
|
||||
10,
|
||||
Math.floor(36 * (timeLeft / duration)),
|
||||
);
|
||||
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.12, 0.3), y: Math.random() - 0.2 },
|
||||
});
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.7, 0.88), y: Math.random() - 0.2 },
|
||||
});
|
||||
}, intervalMs);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
18
studio/frontend/src/features/tour/lib/dom.ts
Normal file
18
studio/frontend/src/features/tour/lib/dom.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { Rect } from "../types";
|
||||
|
||||
export function cssEscape(value: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(value);
|
||||
}
|
||||
return value.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
export function toRect(domRect: DOMRect): Rect {
|
||||
return {
|
||||
x: domRect.left,
|
||||
y: domRect.top,
|
||||
w: domRect.width,
|
||||
h: domRect.height,
|
||||
};
|
||||
}
|
||||
|
||||
66
studio/frontend/src/features/tour/lib/layout.ts
Normal file
66
studio/frontend/src/features/tour/lib/layout.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { Placement, Rect } from "../types";
|
||||
|
||||
export function clamp(n: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
export 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 };
|
||||
}
|
||||
|
||||
export 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";
|
||||
}
|
||||
|
||||
export function computeCardPos(
|
||||
placement: Placement,
|
||||
target: Rect,
|
||||
card: { w: number; h: number },
|
||||
vw: number,
|
||||
vh: number,
|
||||
gap: number,
|
||||
): { left: number; top: 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 };
|
||||
}
|
||||
|
||||
13
studio/frontend/src/features/tour/types.ts
Normal file
13
studio/frontend/src/features/tour/types.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export type TourStep = {
|
||||
id: string;
|
||||
target: string; // data-tour="<target>"
|
||||
title: string;
|
||||
body: ReactNode;
|
||||
};
|
||||
|
||||
export type Rect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
export type Placement = "right" | "left" | "top" | "bottom";
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue