add help mode

This commit is contained in:
2026-02-01 23:27:00 -05:00
parent 5b7d4e79fe
commit 54c34b6f7d
14 changed files with 543 additions and 92 deletions

View File

@@ -37,17 +37,21 @@
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@types/js-cookie": "^3.0.6",
"@types/ws": "^8.18.1",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"driver.js": "^1.4.0",
"drizzle-orm": "^0.41.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.536.0",
"minio": "^8.0.6",
"next": "^16.0.10",
"next-auth": "^5.0.0-beta.29",
"next-themes": "^0.4.6",
"postgres": "^3.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -631,6 +635,8 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
@@ -845,6 +851,8 @@
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"driver.js": ["driver.js@1.4.0", "", {}, "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew=="],
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
"drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="],
@@ -1085,6 +1093,8 @@
"jose": ["jose@6.0.12", "", {}, "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ=="],
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
@@ -1185,6 +1195,8 @@
"next-auth": ["next-auth@5.0.0-beta.29", "", { "dependencies": { "@auth/core": "0.40.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],

View File

@@ -55,17 +55,21 @@
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@types/js-cookie": "^3.0.6",
"@types/ws": "^8.18.1",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"driver.js": "^1.4.0",
"drizzle-orm": "^0.41.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.536.0",
"minio": "^8.0.6",
"next": "^16.0.10",
"next-auth": "^5.0.0-beta.29",
"next-themes": "^0.4.6",
"postgres": "^3.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -13,6 +13,7 @@ import {
BreadcrumbDisplay,
} from "~/components/ui/breadcrumb-provider";
import { StudyProvider } from "~/lib/study-context";
import { TourProvider } from "~/components/onboarding/TourProvider";
interface DashboardLayoutProps {
children: React.ReactNode;
@@ -41,6 +42,7 @@ export default async function DashboardLayout({
return (
<StudyProvider initialStudyId={selectedStudyCookie}>
<TourProvider>
<BreadcrumbProvider>
<SidebarProvider defaultOpen={defaultOpen}>
<AppSidebar userRole={userRole} />
@@ -58,6 +60,7 @@ export default async function DashboardLayout({
</SidebarInset>
</SidebarProvider>
</BreadcrumbProvider>
</TourProvider>
</StudyProvider>
);
}

View File

@@ -11,6 +11,7 @@ import {
Calendar,
CheckCircle2,
Clock,
HelpCircle,
LayoutDashboard,
MoreHorizontal,
Play,
@@ -47,8 +48,10 @@ import {
import { Badge } from "~/components/ui/badge";
import { ScrollArea } from "~/components/ui/scroll-area";
import { api } from "~/trpc/react";
import { useTour } from "~/components/onboarding/TourProvider";
export default function DashboardPage() {
const { startTour } = useTour();
const [studyFilter, setStudyFilter] = React.useState<string | null>(null);
// --- Data Fetching ---
@@ -81,7 +84,7 @@ export default function DashboardPage() {
return (
<div className="flex flex-col space-y-8 animate-in fade-in duration-500">
{/* Header Section */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div id="dashboard-header" className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-foreground">Dashboard</h1>
<p className="text-muted-foreground">
@@ -90,6 +93,9 @@ export default function DashboardPage() {
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => startTour("dashboard")} title="Start Tour">
<HelpCircle className="h-5 w-5" />
</Button>
<Select
value={studyFilter ?? "all"}
onValueChange={(value) =>
@@ -109,7 +115,7 @@ export default function DashboardPage() {
</SelectContent>
</Select>
<Button asChild>
<Button id="tour-new-study" asChild>
<Link href="/studies/new">
<Plus className="mr-2 h-4 w-4" /> New Study
</Link>
@@ -118,7 +124,7 @@ export default function DashboardPage() {
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div id="tour-dashboard-stats" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Total Participants"
value={stats?.totalParticipants ?? 0}
@@ -153,7 +159,7 @@ export default function DashboardPage() {
<div className="col-span-4 space-y-4">
{/* Scheduled Trials */}
<Card className="col-span-4 border-muted/40 shadow-sm">
<Card id="tour-scheduled-trials" className="col-span-4 border-muted/40 shadow-sm">
<CardHeader>
<div className="flex items-center justify-between">
<div>
@@ -252,7 +258,7 @@ export default function DashboardPage() {
</div>
{/* Recent Activity */}
<Card className="border-muted/40 shadow-sm h-full">
<Card id="tour-recent-activity" className="border-muted/40 shadow-sm h-full">
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>

View File

@@ -276,7 +276,7 @@ export function AppSidebar({
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-full">
<SidebarMenuButton className="w-full" id="tour-sidebar-study-selector">
<Building className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{selectedStudy?.name ?? "Select Study"}
@@ -325,7 +325,7 @@ export function AppSidebar({
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-full">
<SidebarMenuButton className="w-full" id="tour-sidebar-study-selector">
<Building className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{selectedStudy?.name ?? "Select Study"}
@@ -383,7 +383,11 @@ export function AppSidebar({
(item.url !== "/dashboard" && pathname.startsWith(item.url));
const menuButton = (
<SidebarMenuButton asChild isActive={isActive}>
<SidebarMenuButton
asChild
isActive={isActive}
id={`tour-sidebar-${item.title.toLowerCase()}`}
>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
@@ -425,7 +429,11 @@ export function AppSidebar({
pathname.startsWith(item.url));
const menuButton = (
<SidebarMenuButton asChild isActive={isActive}>
<SidebarMenuButton
asChild
isActive={isActive}
id={`tour-sidebar-study-${item.title.toLowerCase()}`}
>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>

View File

@@ -8,10 +8,11 @@ import React, {
useState,
} from "react";
import { toast } from "sonner";
import { Play, RefreshCw } from "lucide-react";
import { Play, RefreshCw, HelpCircle } from "lucide-react";
import { cn } from "~/lib/utils";
import { PageHeader } from "~/components/ui/page-header";
import { useTour } from "~/components/onboarding/TourProvider";
import { Button } from "~/components/ui/button";
import { api } from "~/trpc/react";
@@ -151,6 +152,8 @@ export function DesignerRoot({
autoCompile = true,
onPersist,
}: DesignerRootProps) {
const { startTour } = useTour();
/* ----------------------------- Remote Experiment ------------------------- */
const {
data: experiment,
@@ -899,18 +902,25 @@ export function DesignerRoot({
/* ------------------------------- Panels ---------------------------------- */
const leftPanel = useMemo(
() => (
<div ref={libraryRootRef} data-library-root className="h-full">
<div id="tour-designer-blocks" ref={libraryRootRef} data-library-root className="h-full">
<ActionLibraryPanel />
</div>
),
[],
);
const centerPanel = useMemo(() => <FlowWorkspace />, []);
const centerPanel = useMemo(
() => (
<div id="tour-designer-canvas" className="h-full">
<FlowWorkspace />
</div>
),
[],
);
const rightPanel = useMemo(
() => (
<div className="h-full">
<div id="tour-designer-properties" className="h-full">
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
@@ -932,6 +942,14 @@ export function DesignerRoot({
const actions = (
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => startTour("designer")}
title="Start Tour"
>
<HelpCircle className="h-5 w-5" />
</Button>
<Button
size="sm"
variant="default"

View File

@@ -203,7 +203,7 @@ export function PropertiesPanelBase({
: Zap;
return (
<div className={cn("w-full min-w-0 space-y-3 px-3", className)}>
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties">
{/* Header / Metadata */}
<div className="border-b pb-3">
<div className="mb-2 flex items-center gap-2">
@@ -312,7 +312,7 @@ export function PropertiesPanelBase({
/* --------------------------- Step Properties View --------------------------- */
if (selectedStep) {
return (
<div className={cn("w-full min-w-0 space-y-3 px-3", className)}>
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties">
<div className="border-b pb-2">
<h3 className="flex items-center gap-2 text-sm font-medium">
<div
@@ -439,6 +439,7 @@ export function PropertiesPanelBase({
"flex h-24 items-center justify-center text-center",
className,
)}
id="tour-designer-properties"
>
<div>
<Settings className="text-muted-foreground/50 mx-auto mb-2 h-6 w-6" />

View File

@@ -955,6 +955,7 @@ export function FlowWorkspace({
<div
ref={containerRef}
id="tour-designer-canvas"
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
onScroll={onScroll}
>

View File

@@ -333,7 +333,7 @@ export function ActionLibraryPanel() {
).length;
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex h-full flex-col overflow-hidden" id="tour-designer-blocks">
<div className="bg-background/60 flex-shrink-0 border-b p-2">
<div className="relative mb-2">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />

View File

@@ -0,0 +1,359 @@
"use client";
import React, { createContext, useContext, useEffect, useRef } from "react";
import { driver, type Driver } from "driver.js";
import "driver.js/dist/driver.css";
import { useTheme } from "next-themes";
import { usePathname } from "next/navigation";
import Cookies from "js-cookie";
type TourType = "dashboard" | "study_creation" | "designer" | "wizard" | "full_platform";
interface TourContextType {
startTour: (tour: TourType) => void;
}
const TourContext = createContext<TourContextType | undefined>(undefined);
export function useTour() {
const context = useContext(TourContext);
if (!context) {
throw new Error("useTour must be used within a TourProvider");
}
return context;
}
export function TourProvider({ children }: { children: React.ReactNode }) {
const driverObj = useRef<Driver | null>(null);
const { theme } = useTheme();
const pathname = usePathname();
// --- Multi-page Tour Logic ---
useEffect(() => {
// Check if we are in "Full Platform" mode (Local Storage OR Cookie)
const localMode = localStorage.getItem("hristudio_tour_mode");
const cookieMode = Cookies.get("hristudio_tour_mode");
const tourMode = localMode === "full" || cookieMode === "full" ? "full" : null;
if (tourMode === "full") {
// Re-sync local storage if missing but cookie present
if (localMode !== "full") localStorage.setItem("hristudio_tour_mode", "full");
// Small delay to ensure DOM is ready
const timer = setTimeout(() => {
if (pathname === "/dashboard") {
runTourSegment("dashboard");
} else if (pathname.includes("/studies/new")) {
runTourSegment("study_creation");
} else if (pathname.includes("/designer")) {
runTourSegment("designer");
} else if (pathname.includes("/wizard")) {
runTourSegment("wizard");
}
}, 500); // Reduced delay for snappier feel, but still safe for render
return () => clearTimeout(timer);
}
}, [pathname]);
const runTourSegment = (segment: "dashboard" | "study_creation" | "designer" | "wizard") => {
const isDark = theme === "dark";
// We add a specific class to handle dark/light overrides reliably
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
let steps: any[] = [];
if (segment === "dashboard") {
steps = [
{
element: "#dashboard-header",
popover: {
title: "Overview",
description: "Welcome to HRIStudio. This dashboard gives you a high-level view of your research activities, active studies, and data collection progress.",
side: "bottom",
align: "start",
},
},
{
element: "#tour-sidebar-overview",
popover: {
title: "Navigation: Overview",
description: "Quickly return to this main dashboard from anywhere in the application.",
side: "right",
},
},
{
element: "#tour-sidebar-studies",
popover: {
title: "Navigation: Studies",
description: "Manage all your research studies, IRBs, and team permissions in one place.",
side: "right",
},
},
{
element: "#tour-sidebar-study-selector",
popover: {
title: "Active Study Selector",
description: "Switch between different studies here. Selecting a study unlocks study-specific tools like the Experiment Designer and Data Analytics.",
side: "right",
},
},
{
element: "#tour-new-study",
popover: {
title: "Create a New Study",
description: "Ready to start? Click here to initialize a new research project and define your protocol.",
side: "right",
},
},
];
} else if (segment === "study_creation") {
steps = [
{
element: "#tour-study-name",
popover: {
title: "Naming Your Study",
description: "Choose a concise, descriptive name. This will properly namespace your data, logs, and robot configurations.",
side: "right",
}
},
{
element: "#tour-study-description",
popover: {
title: "Research Protocol",
description: "Add a short description of your methodology or research questions. This helps team members understand the context.",
side: "right",
}
},
{
element: "#tour-study-submit",
popover: {
title: "Initialize Project",
description: "Create the study to access the full suite of tools: Experiment Designer, Wizard Interface, and Analytics.",
side: "top",
}
}
];
} else if (segment === "designer") {
steps = [
{
element: "#tour-designer-blocks",
popover: {
title: "Action Library",
description: "Drag and drop robot behaviors (Speech, Gestures, Movement) onto the canvas. Includes both core actions and those from installed plugins.",
side: "right",
},
},
{
element: "#tour-designer-canvas",
popover: {
title: "Visual Flow Canvas",
description: "Design your experiment logic here. Connect blocks to create sequences, branches, and loops for the robot to execute.",
side: "top",
},
},
{
element: "#tour-designer-properties",
popover: {
title: "Properties Panel",
description: "Select any block to configure its parameters—like speech text, speed, volume, or timeout durations.",
side: "left",
},
},
];
} else if (segment === "wizard") {
steps = [
{
element: "#tour-wizard-controls",
popover: {
title: "Wizard Dashboard",
description: "The command center for running trials. Manually trigger robot actions or override autonomous behaviors in real-time.",
side: "right",
},
},
{
element: "#tour-wizard-timeline",
popover: {
title: "Live Timeline",
description: "See exactly what the robot is doing, what's coming next, and a history of all events in the current session.",
side: "top",
},
},
{
element: "#tour-wizard-robot-status",
popover: {
title: "System Health",
description: "Monitor critical telemetry: battery levels, joint temperatures, and network latency to ensure safety.",
side: "left",
},
},
];
}
driverObj.current = driver({
showProgress: true,
animate: true,
allowClose: true,
steps: steps.map((step) => ({
...step,
popover: {
...step.popover,
popoverClass: `driver-popover-override ${themeClass}`,
},
})),
onDestroyed: () => {
// Persistence handled by localStorage state
}
});
driverObj.current.drive();
};
const startTour = (tour: TourType) => {
if (tour === "full_platform") {
localStorage.setItem("hristudio_tour_mode", "full");
Cookies.set("hristudio_tour_mode", "full", { expires: 7 }); // 7 days persistence
// Trigger current page immediately
if (pathname === "/dashboard") runTourSegment("dashboard");
else if (pathname.includes("/studies/new")) runTourSegment("study_creation");
else if (pathname.includes("/designer")) runTourSegment("designer");
else if (pathname.includes("/wizard")) runTourSegment("wizard");
else runTourSegment("dashboard"); // Fallback
} else {
localStorage.setItem("hristudio_tour_mode", "manual");
Cookies.remove("hristudio_tour_mode");
if (tour === "dashboard") runTourSegment("dashboard");
if (tour === "study_creation") runTourSegment("study_creation");
if (tour === "designer") runTourSegment("designer");
if (tour === "wizard") runTourSegment("wizard");
}
};
return (
<TourContext.Provider value={{ startTour }}>
{children}
<style jsx global>{`
/*
SHADCN/UI THEMING OVERRIDES
CRITICAL: The global variables in globals.css use OKLCH/HSL values directly or with units.
DO NOT wrap variables in hsl() if they are already defined as colors.
Use direct assignment.
*/
.driver-popover-override {
padding: 1.25rem !important;
border-radius: var(--radius) !important;
box-shadow: var(--shadow-xl) !important;
max-width: 420px !important;
/* Background & Text - Match Card Aesthetic */
background-color: var(--card) !important;
color: var(--card-foreground) !important;
border: 1px solid var(--border) !important;
/* Typography */
font-family: var(--font-sans) !important;
}
/* Arrow Styling - Critical for transparent/card matching */
.driver-popover-override .driver-popover-arrow {
border-width: 8px !important;
}
/*
Since driver.js uses borders for arrows, we need to match the specific side.
Using CSS variables requires a bit of trickery because border-color expects distinct values.
We'll target the side classes driver.js adds.
*/
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-left.driver-popover-arrow {
border-left-color: var(--card) !important;
}
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-right.driver-popover-arrow {
border-right-color: var(--card) !important;
}
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-top.driver-popover-arrow {
border-top-color: var(--card) !important;
}
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-bottom.driver-popover-arrow {
border-bottom-color: var(--card) !important;
}
/* Light mode fallbacks (using border color for definition, though card bg is usually sufficient) */
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-left.driver-popover-arrow {
border-left-color: var(--card) !important;
}
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-right.driver-popover-arrow {
border-right-color: var(--card) !important;
}
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-top.driver-popover-arrow {
border-top-color: var(--card) !important;
}
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-bottom.driver-popover-arrow {
border-bottom-color: var(--card) !important;
}
/* Title Styling */
.driver-popover-override .driver-popover-title {
color: var(--foreground) !important;
font-size: 1.125rem !important; /* 18px */
font-weight: 600 !important;
margin-bottom: 0.5rem !important;
letter-spacing: -0.015em !important;
font-family: var(--font-sans) !important;
}
/* Description Styling */
.driver-popover-override .driver-popover-description {
color: var(--muted-foreground) !important;
font-size: 0.875rem !important; /* 14px */
line-height: 1.6 !important;
font-family: var(--font-sans) !important;
}
/* Buttons */
.driver-popover-override .driver-popover-footer button {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
border-radius: calc(var(--radius) - 2px) !important;
padding: 0.5rem 1rem !important;
font-size: 0.875rem !important;
font-weight: 500 !important;
border: none !important;
text-shadow: none !important;
transition-all: 0.2s !important;
font-family: var(--font-sans) !important;
}
.driver-popover-override .driver-popover-footer button:hover {
opacity: 0.9 !important;
transform: translateY(-1px);
}
/* Navigation Buttons (Previous/Next) specifically */
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn {
background-color: transparent !important;
color: var(--muted-foreground) !important;
border: 1px solid var(--border) !important;
}
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn:hover {
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
}
/* Close Button */
.driver-popover-override .driver-popover-close-btn {
color: var(--muted-foreground) !important;
opacity: 0.7 !important;
transition: opacity 0.2s !important;
}
.driver-popover-override .driver-popover-close-btn:hover {
color: var(--foreground) !important;
opacity: 1 !important;
}
`}</style>
</TourContext.Provider>
);
}

View File

@@ -25,6 +25,8 @@ import {
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { useTour } from "~/components/onboarding/TourProvider";
import { Button } from "../ui/button";
const studySchema = z.object({
name: z.string().min(1, "Study name is required").max(255, "Name too long"),
@@ -49,6 +51,7 @@ interface StudyFormProps {
export function StudyForm({ mode, studyId }: StudyFormProps) {
const router = useRouter();
const { startTour } = useTour();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -167,9 +170,9 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
description="Basic information about your research study."
>
<FormField>
<Label htmlFor="name">Study Name *</Label>
<Label htmlFor="tour-study-name">Study Name *</Label>
<Input
id="name"
id="tour-study-name"
{...form.register("name")}
placeholder="Enter study name..."
className={form.formState.errors.name ? "border-red-500" : ""}
@@ -182,9 +185,9 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
</FormField>
<FormField>
<Label htmlFor="description">Description *</Label>
<Label htmlFor="tour-study-description">Description *</Label>
<Textarea
id="description"
id="tour-study-description"
{...form.register("description")}
placeholder="Describe the research objectives, methodology, and expected outcomes..."
rows={4}
@@ -322,6 +325,15 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting}
sidebar={sidebar}
submitButtonId="tour-study-submit"
extraActions={
<Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Help</span>
<div className="flex h-5 w-5 items-center justify-center rounded-full border text-xs text-muted-foreground">?</div>
</div>
</Button>
}
>
{formFields}
</EntityForm>

View File

@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
import { Play, CheckCircle, X, Clock, AlertCircle, HelpCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
@@ -19,6 +19,7 @@ import {
import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos";
import { toast } from "sonner";
import { useTour } from "~/components/onboarding/TourProvider";
interface WizardInterfaceProps {
trial: {
@@ -77,6 +78,7 @@ export const WizardInterface = React.memo(function WizardInterface({
trial: initialTrial,
userRole: _userRole,
}: WizardInterfaceProps) {
const { startTour } = useTour();
const [trial, setTrial] = useState(initialTrial);
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [trialStartTime, setTrialStartTime] = useState<Date | null>(
@@ -654,6 +656,13 @@ export const WizardInterface = React.memo(function WizardInterface({
>
{rosConnected ? "ROS Connected" : "ROS Offline"}
</Badge>
<button
onClick={() => startTour("wizard")}
className="hover:bg-muted p-1 rounded-full transition-colors"
title="Start Tour"
>
<HelpCircle className="h-4 w-4" />
</button>
</div>
</div>
</div>
@@ -664,6 +673,7 @@ export const WizardInterface = React.memo(function WizardInterface({
<ResizablePanel defaultSize={75} minSize={30}>
<PanelsContainer
left={
<div id="tour-wizard-controls" className="h-full">
<WizardControlPanel
trial={trial}
currentStep={currentStep}
@@ -684,8 +694,10 @@ export const WizardInterface = React.memo(function WizardInterface({
onSetAutonomousLife={setAutonomousLife}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
</div>
}
center={
<div id="tour-wizard-timeline" className="h-full">
<WizardExecutionPanel
trial={trial}
currentStep={currentStep}
@@ -705,8 +717,10 @@ export const WizardInterface = React.memo(function WizardInterface({
onCompleteTrial={handleCompleteTrial}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
</div>
}
right={
<div id="tour-wizard-robot-status" className="h-full">
<WizardMonitoringPanel
rosConnected={rosConnected}
rosConnecting={rosConnecting}
@@ -717,6 +731,7 @@ export const WizardInterface = React.memo(function WizardInterface({
executeRosAction={executeRosAction}
readOnly={trial.status === 'completed' || _userRole === 'observer'}
/>
</div>
}
showDividers={true}
className="h-full"

View File

@@ -30,12 +30,14 @@ interface WizardObservationPaneProps {
tags?: string[],
) => Promise<void>;
isSubmitting?: boolean;
readOnly?: boolean;
}
export function WizardObservationPane({
onAddAnnotation,
isSubmitting = false,
trialEvents = [],
readOnly = false,
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
const [note, setNote] = useState("");
const [category, setCategory] = useState("observation");
@@ -82,15 +84,16 @@ export function WizardObservationPane({
<TabsContent value="notes" className="flex-1 flex flex-col p-4 m-0 data-[state=inactive]:hidden">
<div className="flex flex-1 flex-col gap-2">
<Textarea
placeholder="Type your observation here..."
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
className="flex-1 resize-none font-mono text-sm"
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={handleKeyDown}
disabled={readOnly}
/>
<div className="flex items-center gap-2">
<Select value={category} onValueChange={setCategory}>
<Select value={category} onValueChange={setCategory} disabled={readOnly}>
<SelectTrigger className="w-[140px] h-8 text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
@@ -104,11 +107,11 @@ export function WizardObservationPane({
</Select>
<div className="flex flex-1 items-center gap-2 rounded-md border px-2 h-8">
<Tag className="h-3 w-3 text-muted-foreground" />
<Tag className={`h-3 w-3 ${readOnly ? "text-muted-foreground/50" : "text-muted-foreground"}`} />
<input
type="text"
placeholder="Add tags..."
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
placeholder={readOnly ? "" : "Add tags..."}
className="flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
onKeyDown={(e) => {
@@ -118,13 +121,14 @@ export function WizardObservationPane({
}
}}
onBlur={addTag}
disabled={readOnly}
/>
</div>
<Button
size="sm"
onClick={handleSubmit}
disabled={isSubmitting || !note.trim()}
disabled={isSubmitting || !note.trim() || readOnly}
className="h-8"
>
<Send className="mr-2 h-3 w-3" />

View File

@@ -52,6 +52,10 @@ interface EntityFormProps<T extends FieldValues = FieldValues> {
// Custom submit button text
submitText?: string;
submitButtonId?: string;
// Additional header actions
extraActions?: ReactNode;
// Layout
layout?: "default" | "full-width";
@@ -76,6 +80,8 @@ export function EntityForm<T extends FieldValues = FieldValues>({
isDeleting = false,
sidebar,
submitText,
submitButtonId,
extraActions,
layout = "default",
className,
}: EntityFormProps<T>) {
@@ -97,6 +103,7 @@ export function EntityForm<T extends FieldValues = FieldValues>({
icon={Icon}
actions={
<div className="flex items-center space-x-2">
{extraActions}
<Button variant="outline" asChild>
<Link href={backUrl}>
<ArrowLeft className="mr-2 h-4 w-4" />
@@ -161,6 +168,7 @@ export function EntityForm<T extends FieldValues = FieldValues>({
Cancel
</Button>
<Button
id={submitButtonId}
type="submit"
disabled={
isSubmitting ||