Merge pull request #102 from unslothai/feature/guided-tour-p2

feat: Guided tours p2: per-page + navbar trigger
This commit is contained in:
Wasim Yousef Said 2026-02-15 16:10:07 -08:00 committed by GitHub
commit 34483f2325
34 changed files with 969 additions and 525 deletions

3
.gitignore vendored
View file

@ -28,6 +28,9 @@ outputs/
*.swp
*.swo
# oh-my-codex
.omx/
# OS
.DS_Store
Thumbs.db

View file

@ -1,39 +1,26 @@
"use client";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks";
import { cn, formatCompact } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
ArrowDown01Icon,
Logout01Icon,
Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactNode, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import type {
LoraModelOption,
ModelOption,
ModelSelectorChangeMeta,
} from "./model-selector/types";
import { HubModelPicker, LoraModelPicker } from "./model-selector/pickers";
export interface ModelOption {
id: string;
name: string;
description?: string;
icon?: ReactNode;
}
export interface LoraModelOption extends ModelOption {
baseModel?: string;
updatedAt?: number;
}
export interface ModelSelectorChangeMeta {
source: "hub" | "lora";
isLora: boolean;
}
export type { LoraModelOption, ModelOption, ModelSelectorChangeMeta } from "./model-selector/types";
interface ModelSelectorProps {
models: ModelOption[];
@ -46,10 +33,10 @@ interface ModelSelectorProps {
size?: "sm" | "default" | "lg";
className?: string;
contentClassName?: string;
}
function dedupe(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
open?: boolean;
onOpenChange?: (open: boolean) => void;
triggerDataTour?: string;
contentDataTour?: string;
}
function ModelSelectorTrigger({
@ -58,17 +45,20 @@ function ModelSelectorTrigger({
variant = "outline",
size = "default",
className,
dataTour,
}: {
currentModel?: ModelOption;
isLoaded: boolean;
variant?: "outline" | "ghost" | "muted";
size?: "sm" | "default" | "lg";
className?: string;
dataTour?: string;
}) {
return (
<PopoverTrigger asChild={true}>
<button
type="button"
data-tour={dataTour}
className={cn(
"flex items-center gap-2 transition-colors",
variant === "outline" &&
@ -99,268 +89,6 @@ function ModelSelectorTrigger({
);
}
function ListLabel({ children }: { children: ReactNode }) {
return (
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</div>
);
}
function ModelRow({
label,
meta,
selected,
onClick,
}: {
label: string;
meta?: string;
selected?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent",
selected && "bg-accent/60",
)}
>
<span className="min-w-0 flex-1 truncate">{label}</span>
{meta ? (
<span className="shrink-0 text-[10px] text-muted-foreground">{meta}</span>
) : null}
</button>
);
}
function HubModelPicker({
models,
value,
onSelect,
}: {
models: ModelOption[];
value?: string;
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
}) {
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query);
const { results, isLoading, isLoadingMore, fetchMore } = useHfModelSearch(
debouncedQuery,
);
const recommendedIds = useMemo(
() => dedupe([...models.map((model) => model.id), value ?? ""]),
[models, value],
);
const showHfSection = debouncedQuery.trim().length > 0;
const recommendedSet = useMemo(
() => new Set(recommendedIds),
[recommendedIds],
);
const hfIds = useMemo(() => {
if (!showHfSection) {
return [];
}
return results
.map((result) => result.id)
.filter((id) => !recommendedSet.has(id));
}, [recommendedSet, results, showHfSection]);
const metricsById = useMemo(
() =>
new Map(
results.map((result) => [
result.id,
result.totalParams
? formatCompact(result.totalParams)
: `${formatCompact(result.downloads)}`,
]),
),
[results],
);
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore, results.length);
return (
<div className="space-y-2">
<div className="relative">
<HugeiconsIcon
icon={Search01Icon}
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search Hugging Face models"
className="h-9 pl-8 pr-8"
/>
{isLoading && (
<Spinner className="pointer-events-none absolute right-2.5 top-2.5 size-4 text-muted-foreground" />
)}
</div>
<div
ref={scrollRef}
className="max-h-64 overflow-y-auto"
>
<div className="p-1">
{!showHfSection ? (
<>
<ListLabel>Recommended</ListLabel>
{recommendedIds.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No default models.
</div>
) : (
recommendedIds.map((id) => (
<ModelRow
key={id}
label={id}
selected={value === id}
onClick={() => onSelect(id, { source: "hub", isLora: false })}
/>
))
)}
</>
) : null}
{showHfSection ? (
<>
<ListLabel>Hugging Face</ListLabel>
{hfIds.length === 0 && !isLoading ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No matching models.
</div>
) : (
hfIds.map((id) => (
<ModelRow
key={id}
label={id}
meta={metricsById.get(id)}
selected={value === id}
onClick={() => onSelect(id, { source: "hub", isLora: false })}
/>
))
)}
<div ref={sentinelRef} className="h-px" />
{isLoadingMore ? (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
) : null}
</>
) : null}
</div>
</div>
</div>
);
}
function LoraModelPicker({
loraModels,
value,
onSelect,
}: {
loraModels: LoraModelOption[];
value?: string;
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
}) {
const [query, setQuery] = useState("");
const normalized = useMemo(
() =>
loraModels
.map((model) => ({
...model,
baseModel: model.baseModel || model.description || "Unknown base model",
}))
.sort((a, b) => {
const aTime = a.updatedAt ?? -1;
const bTime = b.updatedAt ?? -1;
if (aTime !== bTime) {
return bTime - aTime;
}
const baseCmp = a.baseModel.localeCompare(b.baseModel);
if (baseCmp !== 0) {
return baseCmp;
}
return a.name.localeCompare(b.name);
}),
[loraModels],
);
const grouped = useMemo(() => {
const needle = query.trim().toLowerCase();
const out = new Map<string, LoraModelOption[]>();
for (const model of normalized) {
const searchText = `${model.name} ${model.baseModel} ${model.id}`.toLowerCase();
if (needle && !searchText.includes(needle)) {
continue;
}
const key = model.baseModel || "Unknown base model";
const prev = out.get(key) ?? [];
prev.push(model);
out.set(key, prev);
}
return [...out.entries()].sort((a, b) => {
const aLatest = Math.max(...a[1].map((model) => model.updatedAt ?? -1));
const bLatest = Math.max(...b[1].map((model) => model.updatedAt ?? -1));
if (aLatest !== bLatest) {
return bLatest - aLatest;
}
return a[0].localeCompare(b[0]);
});
}, [normalized, query]);
return (
<div className="space-y-2">
<div className="relative">
<HugeiconsIcon
icon={Search01Icon}
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search local adapters"
className="h-9 pl-8"
/>
</div>
<div className="max-h-64 overflow-y-auto">
<div className="p-1">
{grouped.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">No adapters found.</div>
) : (
grouped.map(([baseModel, adapters], index) => (
<div key={baseModel}>
{index > 0 ? <div className="my-1" /> : null}
<ListLabel>{baseModel}</ListLabel>
{adapters.map((adapter) => (
<ModelRow
key={adapter.id}
label={adapter.name}
meta="LoRA"
selected={value === adapter.id}
onClick={() => onSelect(adapter.id, { source: "lora", isLora: true })}
/>
))}
</div>
))
)}
</div>
</div>
</div>
);
}
function ModelSelectorContent({
models,
loraModels,
@ -368,6 +96,7 @@ function ModelSelectorContent({
onSelect,
onEject,
className,
dataTour,
}: {
models: ModelOption[];
loraModels: LoraModelOption[];
@ -375,12 +104,14 @@ function ModelSelectorContent({
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
onEject?: () => void;
className?: string;
dataTour?: string;
}) {
const hasSelection = Boolean(value);
return (
<PopoverContent
align="start"
data-tour={dataTour}
className={cn("w-[440px] min-w-[440px] gap-0 p-2", className)}
>
<Tabs defaultValue="hub" className="w-full">
@ -430,8 +161,14 @@ export function ModelSelector({
size = "default",
className,
contentClassName,
open: controlledOpen,
onOpenChange,
triggerDataTour,
contentDataTour,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = onOpenChange ?? setUncontrolledOpen;
const [uncontrolled, setUncontrolled] = useState(defaultValue ?? "");
const selected = value ?? uncontrolled;
@ -477,6 +214,7 @@ export function ModelSelector({
variant={variant}
size={size}
className={className}
dataTour={triggerDataTour}
/>
<ModelSelectorContent
models={models}
@ -485,6 +223,7 @@ export function ModelSelector({
onSelect={handleSelect}
onEject={onEject ? handleEject : undefined}
className={contentClassName}
dataTour={contentDataTour}
/>
</Popover>
);

View file

@ -0,0 +1,265 @@
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks";
import { cn, formatCompact } from "@/lib/utils";
import { Search01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useMemo, useState, type ReactNode } from "react";
import type {
LoraModelOption,
ModelOption,
ModelSelectorChangeMeta,
} from "./types";
function dedupe(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
function ListLabel({ children }: { children: ReactNode }) {
return (
<div className="px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</div>
);
}
function ModelRow({
label,
meta,
selected,
onClick,
}: {
label: string;
meta?: string;
selected?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent",
selected && "bg-accent/60",
)}
>
<span className="min-w-0 flex-1 truncate">{label}</span>
{meta ? (
<span className="shrink-0 text-[10px] text-muted-foreground">{meta}</span>
) : null}
</button>
);
}
export function HubModelPicker({
models,
value,
onSelect,
}: {
models: ModelOption[];
value?: string;
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
}) {
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query);
const { results, isLoading, isLoadingMore, fetchMore } = useHfModelSearch(
debouncedQuery,
);
const recommendedIds = useMemo(
() => dedupe([...models.map((model) => model.id), value ?? ""]),
[models, value],
);
const showHfSection = debouncedQuery.trim().length > 0;
const recommendedSet = useMemo(() => new Set(recommendedIds), [recommendedIds]);
const hfIds = useMemo(() => {
if (!showHfSection) return [];
return results
.map((result) => result.id)
.filter((id) => !recommendedSet.has(id));
}, [recommendedSet, results, showHfSection]);
const metricsById = useMemo(
() =>
new Map(
results.map((result) => [
result.id,
result.totalParams
? formatCompact(result.totalParams)
: `${formatCompact(result.downloads)}`,
]),
),
[results],
);
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore, results.length);
return (
<div className="space-y-2">
<div className="relative">
<HugeiconsIcon
icon={Search01Icon}
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search Hugging Face models"
className="h-9 pl-8 pr-8"
/>
{isLoading && (
<Spinner className="pointer-events-none absolute right-2.5 top-2.5 size-4 text-muted-foreground" />
)}
</div>
<div ref={scrollRef} className="max-h-64 overflow-y-auto">
<div className="p-1">
{!showHfSection ? (
<>
<ListLabel>Recommended</ListLabel>
{recommendedIds.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No default models.
</div>
) : (
recommendedIds.map((id) => (
<ModelRow
key={id}
label={id}
selected={value === id}
onClick={() => onSelect(id, { source: "hub", isLora: false })}
/>
))
)}
</>
) : null}
{showHfSection ? (
<>
<ListLabel>Hugging Face</ListLabel>
{hfIds.length === 0 && !isLoading ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No matching models.
</div>
) : (
hfIds.map((id) => (
<ModelRow
key={id}
label={id}
meta={metricsById.get(id)}
selected={value === id}
onClick={() => onSelect(id, { source: "hub", isLora: false })}
/>
))
)}
<div ref={sentinelRef} className="h-px" />
{isLoadingMore ? (
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
) : null}
</>
) : null}
</div>
</div>
</div>
);
}
export function LoraModelPicker({
loraModels,
value,
onSelect,
}: {
loraModels: LoraModelOption[];
value?: string;
onSelect: (id: string, meta: ModelSelectorChangeMeta) => void;
}) {
const [query, setQuery] = useState("");
const normalized = useMemo(
() =>
loraModels
.map((model) => ({
...model,
baseModel: model.baseModel || model.description || "Unknown base model",
}))
.sort((a, b) => {
const aTime = a.updatedAt ?? -1;
const bTime = b.updatedAt ?? -1;
if (aTime !== bTime) return bTime - aTime;
const baseCmp = a.baseModel.localeCompare(b.baseModel);
if (baseCmp !== 0) return baseCmp;
return a.name.localeCompare(b.name);
}),
[loraModels],
);
const grouped = useMemo(() => {
const needle = query.trim().toLowerCase();
const out = new Map<string, LoraModelOption[]>();
for (const model of normalized) {
const searchText = `${model.name} ${model.baseModel} ${model.id}`.toLowerCase();
if (needle && !searchText.includes(needle)) continue;
const key = model.baseModel || "Unknown base model";
const prev = out.get(key) ?? [];
prev.push(model);
out.set(key, prev);
}
return [...out.entries()].sort((a, b) => {
const aLatest = Math.max(...a[1].map((model) => model.updatedAt ?? -1));
const bLatest = Math.max(...b[1].map((model) => model.updatedAt ?? -1));
if (aLatest !== bLatest) return bLatest - aLatest;
return a[0].localeCompare(b[0]);
});
}, [normalized, query]);
return (
<div className="space-y-2">
<div className="relative">
<HugeiconsIcon
icon={Search01Icon}
className="pointer-events-none absolute left-2.5 top-2.5 size-4 text-muted-foreground"
/>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search local adapters"
className="h-9 pl-8"
/>
</div>
<div className="max-h-64 overflow-y-auto">
<div className="p-1">
{grouped.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No adapters found.
</div>
) : (
grouped.map(([baseModel, adapters], index) => (
<div key={baseModel}>
{index > 0 ? <div className="my-1" /> : null}
<ListLabel>{baseModel}</ListLabel>
{adapters.map((adapter) => (
<ModelRow
key={adapter.id}
label={adapter.name}
meta="LoRA"
selected={value === adapter.id}
onClick={() => onSelect(adapter.id, { source: "lora", isLora: true })}
/>
))}
</div>
))
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
import type { ReactNode } from "react";
export interface ModelOption {
id: string;
name: string;
description?: string;
icon?: ReactNode;
}
export interface LoraModelOption extends ModelOption {
baseModel?: string;
updatedAt?: number;
}
export interface ModelSelectorChangeMeta {
source: "hub" | "lora";
isLora: boolean;
}

View file

@ -9,6 +9,7 @@ import {
Analytics01Icon,
ArrowRight01Icon,
Book03Icon,
CursorInfo02Icon,
PackageIcon,
ZapIcon,
} from "@hugeicons/core-free-icons";
@ -16,6 +17,7 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { Link, useRouterState } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { TOUR_OPEN_EVENT } from "@/features/tour";
const NAV_ITEMS = [
{ label: "Studio", href: "/studio", icon: ZapIcon, enabled: true },
@ -28,6 +30,15 @@ export function Navbar() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const [logoHovered, setLogoHovered] = useState(false);
const tourId =
pathname === "/studio"
? "studio"
: pathname === "/chat"
? "chat"
: pathname === "/export"
? "export"
: null;
return (
<header className="top-0 z-40 h-16 w-full">
<div className="mx-auto flex h-full max-w-7xl items-center justify-between px-6">
@ -128,39 +139,56 @@ export function Navbar() {
</nav>
{/* Right: docs link */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 hover:text-emerald-700 transition-colors"
<div className="flex items-center gap-2">
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 hover:text-emerald-700 transition-colors"
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
Learn more
</a>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-80 p-0">
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="group/card flex flex-col gap-1 p-4 no-underline"
>
<p className="text-sm font-semibold font-heading">
Unsloth Documentation
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Guides on fine-tuning LLMs 2x faster with 70% less memory.
Covers LoRA, QLoRA, data formatting, and deployment.
</p>
<span className="mt-1 flex items-center gap-1 text-xs font-medium text-emerald-600 group-hover/card:underline">
Visit docs
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
</span>
</a>
</HoverCardContent>
</HoverCard>
{tourId ? (
<button
type="button"
onClick={() => {
window.dispatchEvent(
new CustomEvent(TOUR_OPEN_EVENT, { detail: { id: tourId } }),
);
}}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Tour"
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />
Learn more
</a>
</HoverCardTrigger>
<HoverCardContent align="end" className="w-80 p-0">
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="group/card flex flex-col gap-1 p-4 no-underline"
>
<p className="text-sm font-semibold font-heading">
Unsloth Documentation
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
Guides on fine-tuning LLMs 2x faster with 70% less memory.
Covers LoRA, QLoRA, data formatting, and deployment.
</p>
<span className="mt-1 flex items-center gap-1 text-xs font-medium text-emerald-600 group-hover/card:underline">
Visit docs
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
</span>
</a>
</HoverCardContent>
</HoverCard>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
</button>
) : null}
</div>
</div>
</header>
);

View file

@ -5,16 +5,8 @@ import {
} from "@/components/assistant-ui/model-selector";
import { Thread } from "@/components/assistant-ui/thread";
import { Button } from "@/components/ui/button";
import {
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { SidebarProvider, SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import {
ColumnInsertIcon,
@ -33,6 +25,7 @@ import {
useRef,
useState,
} from "react";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { db } from "./db";
import { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
@ -46,6 +39,7 @@ import {
} from "./shared-composer";
import { ThreadSidebar } from "./thread-sidebar";
import type { ChatView } from "./types";
import { buildChatTourSteps } from "./tour";
const SingleContent = memo(function SingleContent({
threadId,
@ -92,7 +86,7 @@ const CompareContent = memo(function CompareContent({
return (
<CompareHandlesProvider handlesRef={handlesRef}>
<div className="flex min-h-0 flex-1 flex-col">
<div className="grid min-h-0 flex-1 grid-cols-2 px-0">
<div data-tour="chat-compare-view" className="grid min-h-0 flex-1 grid-cols-2 px-0">
<div className="flex min-h-0 flex-col">
<div className="px-3 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
@ -210,6 +204,9 @@ export function ChatPage(): ReactElement {
newThreadNonce: crypto.randomUUID(),
});
const [settingsOpen, setSettingsOpen] = useState(false);
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const viewBeforeCompareRef = useRef<ChatView | null>(null);
const inferenceParams = useChatRuntimeStore((state) => state.params);
const setInferenceParams = useChatRuntimeStore((state) => state.setParams);
const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
@ -242,6 +239,26 @@ export function ChatPage(): ReactElement {
[],
);
const openModelSelector = useCallback(() => setModelSelectorOpen(true), []);
const closeModelSelector = useCallback(() => setModelSelectorOpen(false), []);
const openSettings = useCallback(() => setSettingsOpen(true), []);
const closeSettings = useCallback(() => setSettingsOpen(false), []);
const openSidebar = useCallback(() => setSidebarOpen(true), []);
const enterCompare = useCallback(() => {
if (viewBeforeCompareRef.current == null) {
viewBeforeCompareRef.current = view;
}
setView({ mode: "compare", pairId: crypto.randomUUID() });
}, [view]);
const exitCompare = useCallback(() => {
const prev = viewBeforeCompareRef.current;
if (!prev) return;
viewBeforeCompareRef.current = null;
setView(prev);
}, []);
const models = useMemo<ModelOption[]>(
() =>
modelsFromStore.map((model) => ({
@ -267,10 +284,42 @@ export function ChatPage(): ReactElement {
void refresh();
}, [refresh]);
const tourSteps = useMemo(
() =>
buildChatTourSteps({
canCompare,
openModelSelector,
closeModelSelector,
openSettings,
closeSettings,
openSidebar,
enterCompare,
exitCompare,
}),
[
canCompare,
closeModelSelector,
closeSettings,
enterCompare,
exitCompare,
openModelSelector,
openSettings,
openSidebar,
],
);
const tour = useGuidedTourController({
id: "chat",
steps: tourSteps,
});
return (
<div className="h-[calc(100vh-4rem)] bg-background overflow-hidden">
<GuidedTour {...tour.tourProps} />
<SidebarProvider
defaultOpen={true}
open={sidebarOpen}
onOpenChange={setSidebarOpen}
className="!min-h-0 h-full max-w-7xl mx-auto px-4"
style={
{
@ -305,6 +354,10 @@ export function ChatPage(): ReactElement {
onValueChange={handleCheckpointChange}
onEject={handleEject}
variant="ghost"
open={modelSelectorOpen}
onOpenChange={setModelSelectorOpen}
triggerDataTour="chat-model-selector"
contentDataTour="chat-model-selector-popover"
/>
</div>
{modelsError && (
@ -318,6 +371,7 @@ export function ChatPage(): ReactElement {
onClick={() => setSettingsOpen((o) => !o)}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Inference settings"
data-tour="chat-settings"
>
<HugeiconsIcon icon={Settings04Icon} className="size-5" />
</button>

View file

@ -116,7 +116,7 @@ export function ThreadSidebar({
</SidebarMenuItem>
{showCompare ? (
<SidebarMenuItem>
<SidebarMenuButton onClick={onNewCompare}>
<SidebarMenuButton data-tour="chat-compare" onClick={onNewCompare}>
<HugeiconsIcon icon={ColumnInsertIcon} />
<span>Compare</span>
</SidebarMenuButton>

View file

@ -0,0 +1,2 @@
export { buildChatTourSteps } from "./steps";

View file

@ -0,0 +1,94 @@
import type { TourStep } from "@/features/tour";
export function buildChatTourSteps({
canCompare,
openModelSelector,
closeModelSelector,
openSettings,
closeSettings,
openSidebar,
enterCompare,
exitCompare,
}: {
canCompare: boolean;
openModelSelector: () => void;
closeModelSelector: () => void;
openSettings: () => void;
closeSettings: () => void;
openSidebar: () => void;
enterCompare: () => void;
exitCompare: () => void;
}): TourStep[] {
const steps: TourStep[] = [
{
id: "model",
target: "chat-model-selector",
title: "Pick a model",
body: (
<>
This selects whats loaded for inference. Hub = base models. Fine-tuned
= your LoRA adapters from Studio.
</>
),
},
{
id: "model-tabs",
target: "chat-model-selector-popover",
title: "Two tabs",
body: (
<>
Hub: search Hugging Face models. Fine-tuned: adapters (LoRA) youve
trained locally. If results look off, compare base vs LoRA to see what
changed.
</>
),
onEnter: openModelSelector,
onExit: closeModelSelector,
},
{
id: "settings",
target: "chat-settings",
title: "Settings sidebar",
body: (
<>
Sampling (temperature/top-p/top-k) + system prompt live here. If you
want more deterministic outputs, lower temperature first.
</>
),
onEnter: openSettings,
onExit: closeSettings,
},
];
if (canCompare) {
steps.push(
{
id: "compare-btn",
target: "chat-compare",
title: "Compare mode",
body: (
<>
When a LoRA is selected, compare base vs fine-tuned side-by-side.
This is the fastest way to sanity-check your training.
</>
),
onEnter: openSidebar,
},
{
id: "compare-view",
target: "chat-compare-view",
title: "Side-by-side threads",
body: (
<>
Same prompt, 2 threads. If LoRA is worse than base, its usually
data formatting, too many epochs, or a bad checkpoint choice.
</>
),
onEnter: enterCompare,
onExit: exitCompare,
},
);
}
return steps;
}

View file

@ -19,7 +19,7 @@ interface MethodPickerProps {
export function MethodPicker({ value, onChange }: MethodPickerProps) {
return (
<div className="flex flex-col gap-3">
<div data-tour="export-method" className="flex flex-col gap-3">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Export Method
<Tooltip>

View file

@ -31,6 +31,8 @@ import {
METHOD_LABELS,
getEstimatedSize,
} from "./constants";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { exportTourSteps } from "./tour";
export function ExportPage() {
const {
@ -89,6 +91,11 @@ export function ExportPage() {
const [modelName, setModelName] = useState("");
const [privateRepo, setPrivateRepo] = useState(false);
const tour = useGuidedTourController({
id: "export",
steps: exportTourSteps,
});
const handleMethodChange = (method: ExportMethod) => {
setExportMethod(method);
if (method !== "gguf") {
@ -106,6 +113,8 @@ export function ExportPage() {
return (
<div className="min-h-screen bg-background">
<main className="mx-auto max-w-7xl px-6 py-4">
<GuidedTour {...tour.tourProps} />
<div className="mb-8 flex flex-col gap-0.5">
<h1 className="text-2xl font-semibold tracking-tight">
Export Model
@ -156,7 +165,7 @@ export function ExportPage() {
</Tooltip>
</label>
<Select value={checkpoint ?? ""} onValueChange={setCheckpoint}>
<SelectTrigger className="w-full">
<SelectTrigger data-tour="export-checkpoint" className="w-full">
<SelectValue
placeholder={
isAdapter ? "Select a checkpoint…" : "Select model…"
@ -250,7 +259,11 @@ export function ExportPage() {
/>
<span>Est. size: {estimatedSize} · Free disk space: 120 GB</span>
</div>
<Button disabled={!canExport} onClick={() => setDialogOpen(true)}>
<Button
data-tour="export-cta"
disabled={!canExport}
onClick={() => setDialogOpen(true)}
>
Export Model
</Button>
</div>

View file

@ -0,0 +1,2 @@
export { exportTourSteps } from "./steps";

View file

@ -0,0 +1,38 @@
import type { TourStep } from "@/features/tour";
export const exportTourSteps: TourStep[] = [
{
id: "checkpoint",
target: "export-checkpoint",
title: "Pick checkpoint",
body: (
<>
Pick which checkpoint to export. If you trained multiple checkpoints,
its worth exporting 1-2 candidates and testing in Chat.
</>
),
},
{
id: "method",
target: "export-method",
title: "Export method",
body: (
<>
Choose the packaging. GGUF is for llama.cpp-style runtimes (pick a
quant). Safetensors is for HF/Transformers-style usage. If youre unsure,
start with safetensors.
</>
),
},
{
id: "cta",
target: "export-cta",
title: "Export",
body: (
<>
Export to local or push to HF Hub. After export, test in Chat and compare
against base to confirm behavior is what you expect.
</>
),
},
];

View file

@ -264,7 +264,7 @@ export function ChartsContent({
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card size="sm">
<Card data-tour="studio-training-loss" size="sm">
<CardHeader>
<CardTitle className="text-sm pl-2">Training Loss</CardTitle>
<CardAction>
@ -546,7 +546,7 @@ export function ChartsContent({
</CardContent>
</Card>
<Card size="sm">
<Card data-tour="studio-eval-loss" size="sm">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground pl-2">
Eval Loss

View file

@ -0,0 +1,41 @@
import type { TrainingPhase } from "@/features/training";
export const phaseLabel: Record<TrainingPhase, string> = {
idle: "Idle",
loading_model: "Loading model",
loading_dataset: "Loading dataset",
configuring: "Configuring",
training: "Training",
completed: "Completed",
error: "Error",
stopped: "Stopped",
};
export const phaseColors: Record<TrainingPhase, string> = {
idle: "bg-muted text-muted-foreground",
loading_model:
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
loading_dataset:
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
configuring: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
training:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
completed:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
error: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
stopped: "bg-muted text-muted-foreground",
};
export function formatDuration(seconds: number | null): string {
if (seconds == null || seconds < 0) return "--";
const total = Math.floor(seconds);
const min = Math.floor(total / 60);
const sec = total % 60;
return `${min}m ${sec}s`;
}
export function formatNumber(value: number | null | undefined, digits: number): string {
if (value == null || !Number.isFinite(value)) return "--";
return value.toFixed(digits);
}

View file

@ -19,7 +19,6 @@ import {
useTrainingConfigStore,
useTrainingActions,
useTrainingRuntimeStore,
type TrainingPhase,
} from "@/features/training";
import {
ChartAverageIcon,
@ -33,48 +32,7 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
import { useShallow } from "zustand/react/shallow";
const phaseLabel: Record<TrainingPhase, string> = {
idle: "Idle",
loading_model: "Loading model",
loading_dataset: "Loading dataset",
configuring: "Configuring",
training: "Training",
completed: "Completed",
error: "Error",
stopped: "Stopped",
};
const phaseColors: Record<TrainingPhase, string> = {
idle: "bg-muted text-muted-foreground",
loading_model: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
loading_dataset:
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
configuring: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
training:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
completed:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
error: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
stopped: "bg-muted text-muted-foreground",
};
function formatDuration(seconds: number | null): string {
if (seconds == null || seconds < 0) {
return "--";
}
const total = Math.floor(seconds);
const min = Math.floor(total / 60);
const sec = total % 60;
return `${min}m ${sec}s`;
}
function formatNumber(value: number | null | undefined, digits: number): string {
if (value == null || !Number.isFinite(value)) {
return "--";
}
return value.toFixed(digits);
}
import { formatDuration, formatNumber, phaseColors, phaseLabel } from "./progress-section-lib";
export function ProgressSection(): ReactElement {
const runtime = useTrainingRuntimeStore(
@ -238,6 +196,7 @@ export function ProgressSection(): ReactElement {
</Popover>
<AlertDialog open={stopDialogOpen} onOpenChange={setStopDialogOpen}>
<Button
data-tour="studio-training-stop"
variant="destructive"
size="sm"
className="h-7 cursor-pointer px-3 text-xs"

View file

@ -5,11 +5,11 @@ import {
useTrainingRuntimeLifecycle,
useTrainingRuntimeStore,
} from "@/features/training";
import { GuidedTour } from "@/features/tour";
import { studioTourSteps } from "@/features/studio/tour";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { studioTourSteps, studioTrainingTourSteps } from "@/features/studio/tour";
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useEffect, useState } from "react";
import { type ReactElement, useEffect } from "react";
import { DatasetSection } from "./sections/dataset-section";
import { ModelSection } from "./sections/model-section";
import { ParamsSection } from "./sections/params-section";
@ -28,25 +28,25 @@ export function StudioPage(): ReactElement {
const { dismissTrainingRun } = useTrainingActions();
const canGoBack = runtimePhase === "stopped" || runtimePhase === "error";
const tourEnabled = hasHydratedRuntime && !isHydratingRuntime && !showTrainingView;
const [tourOpen, setTourOpen] = useState(false);
const tourEnabled = hasHydratedRuntime && !isHydratingRuntime;
const isConfigTour = !showTrainingView;
const tourSteps = showTrainingView ? studioTrainingTourSteps : studioTourSteps;
const tour = useGuidedTourController({
id: "studio",
steps: tourSteps,
enabled: tourEnabled,
autoKey: isConfigTour ? STUDIO_TOUR_KEY : undefined,
autoWhen: isConfigTour,
});
useEffect(() => {
if (!tourEnabled) return;
if (localStorage.getItem(STUDIO_TOUR_KEY)) return;
setTourOpen(true);
}, [tourEnabled]);
tour.setOpen(false);
}, [showTrainingView, tour.setOpen]);
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")}
/>
<GuidedTour {...tour.tourProps} celebrate={true} />
{canGoBack && (
<Button

View file

@ -1,2 +1,2 @@
export { studioTourSteps } from "./steps";
export { studioTrainingTourSteps } from "./training";

View file

@ -6,8 +6,10 @@ export const studioBaseModelStep: TourStep = {
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 />
Paste <span className="font-mono">org/model</span> or search. Pick a base
model close to your task (chat/instruct vs base). Smaller models iterate
faster; scale up once prompts + data look good.{" "}
<ReadMore href="https://docs.unsloth.ai/basics/fine-tuning-llms-guide" />
</>
),
};

View file

@ -6,8 +6,12 @@ export const studioDatasetStep: TourStep = {
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 />
Search Hub or paste <span className="font-mono">user/dataset</span>. Preview
a few rows: formatting matters more than size. Well try to auto-convert
your dataset into a supported training format. If we cant infer it
cleanly, well prompt you to map the fields manually. If outputs look off
in Chat later, dataset formatting/template is the first thing to check.{" "}
<ReadMore href="https://docs.unsloth.ai/basics/fine-tuning-llms-guide" />
</>
),
};

View file

@ -6,9 +6,10 @@ export const studioLocalModelStep: TourStep = {
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 />
Use this if you already downloaded weights locally (eg{" "}
<span className="font-mono">./models/...</span>) to avoid re-downloading.
Folder should look like a Hugging Face model (config + tokenizer + weights).{" "}
<ReadMore href="https://docs.unsloth.ai/basics/fine-tuning-llms-guide" />
</>
),
};

View file

@ -6,8 +6,10 @@ export const studioMethodStep: TourStep = {
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 />
LoRA: trains small adapter weights (fast, common default). QLoRA: LoRA on
4-bit base weights (much lower VRAM). Full: updates all weights (highest
cost, usually needs more data to be worth it).{" "}
<ReadMore href="https://docs.unsloth.ai/basics/lora-hyperparameters-guide" />
</>
),
};

View file

@ -6,9 +6,9 @@ export const studioNavStep: TourStep = {
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).
Studio: pick base model, dataset, hyperparams, then start training. After
you start, youll see a Training view with live loss/metrics. Chat is for
testing base vs LoRA adapters. Export packages checkpoints for deployment.
</>
),
};

View file

@ -6,8 +6,10 @@ export const studioParamsStep: TourStep = {
title: "Dial hyperparams",
body: (
<>
Epochs + context length + LR. Keep it boring: small changes, one at a
time. <ReadMore />
Start boring, then iterate. We usually recommend starting with 1-3 epochs
(higher can overfit fast). If youre unsure, change 1 knob at a time, and
watch train vs eval loss.{" "}
<ReadMore href="https://docs.unsloth.ai/basics/lora-hyperparameters-guide" />
</>
),
};

View file

@ -6,9 +6,8 @@ export const studioSaveStep: TourStep = {
title: "Save config",
body: (
<>
Save good runs. Repeatability beats vibe. You can iterate from a known
baseline.
Save configs that worked. Re-running the same baseline makes it obvious
if a change helped (or if you just got lucky).
</>
),
};

View file

@ -6,9 +6,9 @@ export const studioStartStep: TourStep = {
title: "Start training",
body: (
<>
One click. If it fails, the error text is the first place to look (token,
path, config).
Kick off training. If it errors immediately, check HF token / local paths
/ dataset access first. Start with a small run to sanity-check loss + sample
outputs before burning hours.
</>
),
};

View file

@ -0,0 +1,2 @@
export { studioTrainingTourSteps } from "./steps";

View file

@ -0,0 +1,63 @@
import type { TourStep } from "@/features/tour";
export const studioTrainingTourSteps: TourStep[] = [
{
id: "nav",
target: "navbar",
title: "Training view",
body: (
<>
This view updates live as training runs. Watch loss, speed, and ETA, and
use Stop if you need to bail out or save.
</>
),
},
{
id: "progress",
target: "studio-training-progress",
title: "Progress + ETA",
body: (
<>
Phase shows what were doing (loading model/dataset, configuring,
training). ETA is rough early on; it stabilizes after a few steps.
</>
),
},
{
id: "train-loss",
target: "studio-training-loss",
title: "Training loss",
body: (
<>
Training loss should generally trend down. Absolute values vary by
dataset + tokenizer, so use it for direction more than a magic number.
If loss goes very low (eg below ~0.2), that can be a sign youre
overfitting. If loss plateaus high, you likely need better data
formatting, more data, or different hyperparams.
</>
),
},
{
id: "eval-loss",
target: "studio-eval-loss",
title: "Eval loss (validation)",
body: (
<>
Eval loss is your sanity check. If training loss keeps dropping but eval
loss goes up, youre likely overfitting. To track it, set an eval dataset
and `eval_steps` (setting `eval_steps=1` can be very slow).
</>
),
},
{
id: "stop",
target: "studio-training-stop",
title: "Stop / save",
body: (
<>
Stop training any time. Stop and Save keeps the checkpoint/adapters so
you can export or compare later.
</>
),
},
];

View file

@ -33,7 +33,9 @@ export function TrainingView(): ReactElement {
<div
className={cn("flex flex-col gap-6 transition-[filter]", showOverlay && "blur")}
>
<ProgressSection />
<div data-tour="studio-training-progress">
<ProgressSection />
</div>
<ChartsSection />
</div>
{showOverlay ? (

View file

@ -1,90 +1,17 @@
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 { 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 { 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 { SpotlightOverlay } from "./spotlight-overlay";
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;
};
type GuidedTourProps = { open: boolean; onOpenChange: (open: boolean) => void; steps: TourStep[]; onSkip: () => void; onComplete: () => void; celebrate?: boolean }; // confetti on complete only
export function GuidedTour({
open,
@ -92,6 +19,7 @@ export function GuidedTour({
steps,
onSkip,
onComplete,
celebrate = false,
}: GuidedTourProps) {
const maskId = `${useId()}-tour-mask`;
const [idx, setIdx] = useState(0);
@ -107,6 +35,7 @@ export function GuidedTour({
const closeLockRef = useRef(false);
const rafRef = useRef<number | null>(null);
const lastRectRef = useRef<Rect | null>(null);
const activeStepRef = useRef<TourStep | null>(null);
const step = steps[idx] ?? null;
const total = steps.length;
@ -118,6 +47,27 @@ export function GuidedTour({
return padded(targetRect, pad, vw, vh);
}, [step?.target, targetRect, vw, vh]);
useEffect(() => {
if (!open) return;
const prev = activeStepRef.current;
if (prev && prev.id !== step?.id) {
void prev.onExit?.();
}
activeStepRef.current = step;
if (step) {
void step.onEnter?.();
}
}, [open, step?.id]); // run before target lookup effect below
useEffect(() => {
if (open) return;
const prev = activeStepRef.current;
activeStepRef.current = null;
if (prev) {
void prev.onExit?.();
}
}, [open]);
useEffect(() => {
if (!open) return;
setIdx(0);
@ -141,24 +91,25 @@ export function GuidedTour({
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 el: HTMLElement | null = null;
let ro: ResizeObserver | null = null;
let retryTimer = 0;
let retries = 0;
let raf = 0;
let t = 0;
function findTarget(): HTMLElement | null {
const found = document.querySelector(sel);
if (!(found instanceof HTMLElement)) return null;
return found;
}
function isUsableTarget(candidate: HTMLElement): boolean {
const r = candidate.getBoundingClientRect();
return r.width >= 6 && r.height >= 6;
}
function rectChanged(a: Rect | null, b: Rect): boolean {
if (!a) return true;
return (
@ -169,8 +120,8 @@ export function GuidedTour({
);
}
function read() {
const r = el.getBoundingClientRect();
function read(candidate: HTMLElement) {
const r = candidate.getBoundingClientRect();
const next = toRect(r);
const prev = lastRectRef.current;
if (rectChanged(prev, next)) {
@ -183,22 +134,53 @@ export function GuidedTour({
if (rafRef.current != null) return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
read();
if (el) read(el);
});
}
raf = window.requestAnimationFrame(read);
t = window.setTimeout(schedule, 240);
function attach(candidate: HTMLElement) {
el = candidate;
const ro = new ResizeObserver(() => schedule());
ro.observe(el);
window.addEventListener("scroll", schedule, { capture: true, passive: true });
window.addEventListener("resize", schedule, { passive: true });
if (step.target !== "navbar") {
el.scrollIntoView({
block: "center",
inline: "center",
behavior: "smooth",
});
}
raf = window.requestAnimationFrame(() => read(el!));
t = window.setTimeout(schedule, 240);
ro = new ResizeObserver(() => schedule());
ro.observe(el);
window.addEventListener("scroll", schedule, { capture: true, passive: true });
window.addEventListener("resize", schedule, { passive: true });
}
function tryAttach(): boolean {
const candidate = findTarget();
if (!candidate) return false;
if (!isUsableTarget(candidate)) return false;
attach(candidate);
return true;
}
if (!tryAttach()) {
setTargetRect(null);
retryTimer = window.setInterval(() => {
retries += 1;
if (tryAttach() || retries > 40) {
window.clearInterval(retryTimer);
}
}, 50);
}
return () => {
window.cancelAnimationFrame(raf);
window.clearTimeout(t);
ro.disconnect();
if (retryTimer) window.clearInterval(retryTimer);
ro?.disconnect();
window.removeEventListener("scroll", schedule, true);
window.removeEventListener("resize", schedule);
if (rafRef.current != null) {
@ -206,7 +188,7 @@ export function GuidedTour({
rafRef.current = null;
}
};
}, [open, step]);
}, [open, step?.id]);
useLayoutEffect(() => {
if (!open || !spotlightRect || !vw || !vh) return;
@ -231,9 +213,12 @@ export function GuidedTour({
function requestClose(reason: "skip" | "complete") {
if (closeLockRef.current) return;
closeLockRef.current = true;
void fireConfettiFireworks();
if (reason === "skip") onSkip();
else onComplete();
if (reason === "skip") {
onSkip();
} else {
if (celebrate) void fireConfettiFireworks();
onComplete();
}
onOpenChange(false);
}

View file

@ -0,0 +1,52 @@
import { motion } from "motion/react";
import type { Rect } from "../types";
type SpotlightOverlayProps = {
rect: Rect | null;
vw: number;
vh: number;
maskId: string;
};
export 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>
);
}

View file

@ -0,0 +1,71 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { TourStep } from "../types";
export const TOUR_OPEN_EVENT = "omx:tour:open";
export type TourOpenDetail = {
id?: string;
};
export function useGuidedTourController({
id,
steps,
enabled = true,
autoKey,
autoWhen = false,
}: {
id: string;
steps: TourStep[];
enabled?: boolean;
autoKey?: string;
autoWhen?: boolean;
}) {
const [open, setOpen] = useState(false);
const [hasRuntime, setHasRuntime] = useState(false);
useEffect(() => setHasRuntime(true), []);
useEffect(() => {
if (!hasRuntime || !enabled) return;
if (!autoKey || !autoWhen) return;
if (steps.length === 0) return;
if (localStorage.getItem(autoKey)) return;
setOpen(true);
}, [autoKey, autoWhen, enabled, hasRuntime, steps.length]);
useEffect(() => {
if (!hasRuntime || !enabled) return;
function onOpen(e: Event) {
const ce = e as CustomEvent<TourOpenDetail>;
if (ce.detail?.id && ce.detail.id !== id) return;
if (steps.length === 0) return;
setOpen(true);
}
window.addEventListener(TOUR_OPEN_EVENT, onOpen);
return () => window.removeEventListener(TOUR_OPEN_EVENT, onOpen);
}, [enabled, hasRuntime, id, steps.length]);
const onSkip = useCallback(() => {
if (!autoKey) return;
localStorage.setItem(autoKey, "skipped");
}, [autoKey]);
const onComplete = useCallback(() => {
if (!autoKey) return;
localStorage.setItem(autoKey, "done");
}, [autoKey]);
const tourProps = useMemo(
() => ({
open,
onOpenChange: setOpen,
steps,
onSkip,
onComplete,
}),
[onComplete, onSkip, open, steps],
);
return { open, setOpen, onSkip, onComplete, tourProps };
}

View file

@ -1,3 +1,4 @@
export { GuidedTour } from "./components/guided-tour";
export { ReadMore } from "./components/read-more";
export { TOUR_OPEN_EVENT, useGuidedTourController } from "./hooks/use-guided-tour-controller";
export type { TourStep } from "./types";

View file

@ -5,9 +5,10 @@ export type TourStep = {
target: string; // data-tour="<target>"
title: string;
body: ReactNode;
onEnter?: () => void | Promise<void>;
onExit?: () => void | Promise<void>;
};
export type Rect = { x: number; y: number; w: number; h: number };
export type Placement = "right" | "left" | "top" | "bottom";