@@ -1443,6 +1445,7 @@ export function Chat({ sessionId, placeholder }: ChatProps) {
title="Attach and tools"
aria-label="Attach and tools"
aria-expanded={plusOpen}
+ {...tourAnchorProps('chat-plus')}
>
{enhancing
?
diff --git a/app/ui_layer/browser/frontend/src/components/layout/Layout.tsx b/app/ui_layer/browser/frontend/src/components/layout/Layout.tsx
index 6d69fb28..20f57fa1 100644
--- a/app/ui_layer/browser/frontend/src/components/layout/Layout.tsx
+++ b/app/ui_layer/browser/frontend/src/components/layout/Layout.tsx
@@ -3,8 +3,13 @@ import { useLocation } from 'react-router-dom'
import { Menu, X } from 'lucide-react'
import { NavBar } from './NavBar'
import { useFullscreen } from '../../contexts/FullscreenContext'
+import { useTourEnvAction } from '../../tour'
import styles from './Layout.module.css'
+// Matches the mobile breakpoint in Layout.module.css, where the sidebar
+// becomes an off-canvas drawer.
+const MOBILE_QUERY = '(max-width: 768px)'
+
interface LayoutProps {
children: ReactNode
}
@@ -54,6 +59,16 @@ export function Layout({ children }: LayoutProps) {
})
}
+ // Let the guided tour reveal the sidebar before highlighting a nav item.
+ // Expanding it in memory only (not persisting COLLAPSED_KEY) keeps the user's
+ // saved preference intact for their next session.
+ useTourEnvAction('ensureSidebarVisible', () => {
+ setCollapsed(false)
+ if (window.matchMedia(MOBILE_QUERY).matches) {
+ setMobileOpen(true)
+ }
+ })
+
return (
{!isFullscreen && (
diff --git a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx
index ac335022..29f04c30 100644
--- a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx
+++ b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx
@@ -23,6 +23,7 @@ import {
} from 'lucide-react'
import { useWebSocket } from '../../contexts/WebSocketContext'
import { useTheme } from '../../contexts/ThemeContext'
+import { tourAnchorProps, useTourEnvAction, type TourAnchorId } from '../../tour'
import { useSkillCreator } from '../../hooks'
import { CreateLivingUIModal } from '../ui/CreateLivingUIModal'
import { SkillCreatorModal } from '../ui/SkillCreatorModal'
@@ -39,6 +40,7 @@ interface NavItem {
label: string
icon: React.ReactNode
path: string
+ tourAnchor?: TourAnchorId
}
// Sidebar title with a typewriter reveal: when the auto-title replaces the
@@ -84,8 +86,8 @@ function AnimatedSessionTitle({ title }: { title: string }) {
}
const utilityNavItems: NavItem[] = [
- { id: 'dashboard', label: 'Dashboard', icon:
, path: '/dashboard' },
- { id: 'workspace', label: 'Workspace', icon:
, path: '/workspace' },
+ { id: 'dashboard', label: 'Dashboard', icon:
, path: '/dashboard', tourAnchor: 'nav-dashboard' },
+ { id: 'workspace', label: 'Workspace', icon:
, path: '/workspace', tourAnchor: 'nav-workspace' },
]
const settingsItem: NavItem = { id: 'settings', label: 'Settings', icon:
, path: '/settings' }
@@ -322,6 +324,19 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
navigate('/session/new')
}
+ // Let the guided tour open a fresh New Chat via the exact same action as the
+ // button, so the chat is demonstrated on a clean draft, not the Main session.
+ useTourEnvAction('openNewChat', startNewChat)
+
+ // Let the tour expand the Chats group so the pinned Main row is on screen
+ // before it highlights it.
+ useTourEnvAction('ensureChatsExpanded', () => setChatsExpanded(true))
+
+ // Let the tour open and close the "Add Living UI" modal while it walks the
+ // creation methods.
+ useTourEnvAction('openLivingUIModal', () => setShowCreateModal(true))
+ useTourEnvAction('closeLivingUIModal', () => setShowCreateModal(false))
+
// Close any open context menu when clicking anywhere else.
useEffect(() => {
if (!menu) return
@@ -455,6 +470,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
key={session.id}
className={`${styles.sessionRow} ${active ? styles.sessionRowActive : ''} ${opts.isMain ? styles.sessionRowMain : ''}`}
title={opts.isMain ? 'Main' : session.title}
+ {...(opts.isMain ? tourAnchorProps('nav-main-session') : {})}
>
{renaming ? (
New Chat
@@ -594,6 +611,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
className={`${styles.navItem} ${isActive(item.path) ? styles.active : ''}`}
onClick={() => navigate(item.path)}
title={item.label}
+ {...(item.tourAnchor ? tourAnchorProps(item.tourAnchor) : {})}
>
{item.icon}
{item.label}
@@ -631,7 +649,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
) : (
<>
{/* Living UI group */}
-
+
setLivingUIExpanded(v => !v)}
@@ -697,7 +715,7 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) {
{/* Chats group — Main always pinned first inside it */}
-
+
setChatsExpanded(v => !v)}
diff --git a/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.tsx b/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.tsx
index aec6bde3..b2eee7da 100644
--- a/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.tsx
+++ b/app/ui_layer/browser/frontend/src/components/ui/CreateLivingUIModal.tsx
@@ -4,8 +4,16 @@ import { Button } from './Button'
import { Modal } from './Modal'
import { CreateCustomWizard } from './CreateCustomWizard'
import { useSettingsWebSocket } from '../../pages/Settings/useSettingsWebSocket'
+import { tourAnchorProps, useTourEnvAction, type TourAnchorId } from '../../tour'
import styles from './CreateLivingUIModal.module.css'
+// The modal's tabs, in the order the guided tour walks them.
+const TAB_TOUR_ANCHORS: Record<'marketplace' | 'custom' | 'import', TourAnchorId> = {
+ marketplace: 'livingui-tab-marketplace',
+ custom: 'livingui-tab-custom',
+ import: 'livingui-tab-import',
+}
+
export interface CreateLivingUIModalProps {
isOpen: boolean
onClose: () => void
@@ -63,6 +71,13 @@ export function CreateLivingUIModal({ isOpen, onClose, onInstalled }: CreateLivi
useEffect(() => { onInstalledRef.current = onInstalled }, [onInstalled])
useEffect(() => () => { installTimeoutsRef.current.forEach(t => clearTimeout(t)) }, [])
+ // Let the guided tour switch the modal's tab so each creation method is shown.
+ useTourEnvAction('openLivingUITab', (arg) => {
+ if (arg === 'marketplace' || arg === 'custom' || arg === 'import') {
+ setActiveTab(arg)
+ }
+ })
+
// Chat-path requirements phase: living_ui_scaffold generated setup
// questions (creating nothing yet) and the backend summons the SAME
// Create Custom wizard, pre-seeded and opened at the interview step
@@ -348,6 +363,7 @@ export function CreateLivingUIModal({ isOpen, onClose, onInstalled }: CreateLivi
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`${styles.tab} ${activeTab === tab.id ? styles.tabActive : ''}`}
+ {...tourAnchorProps(TAB_TOUR_ANCHORS[tab.id])}
>
{tab.icon}
{tab.label}
diff --git a/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/CraftBotIntroWidget.tsx b/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/CraftBotIntroWidget.tsx
index f53def10..77f4b418 100644
--- a/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/CraftBotIntroWidget.tsx
+++ b/app/ui_layer/browser/frontend/src/pages/Dashboard/widgets/CraftBotIntroWidget.tsx
@@ -1,8 +1,9 @@
import { useState, useRef, useEffect } from 'react'
-import { Cloud, Users, Github, Box, ChevronRight, ArrowLeft, ExternalLink } from 'lucide-react'
+import { Cloud, Users, Github, Box, ChevronRight, ArrowLeft, ExternalLink, Compass } from 'lucide-react'
import { CraftBotMascot, useMascotState, getPose } from '@mascot'
import type { MascotState } from '@mascot'
import { Button } from '../../../components/ui'
+import { useTour } from '../../../tour'
import styles from './widgets.module.css'
interface IntroCard {
@@ -99,6 +100,7 @@ const CARDS: IntroCard[] = [
]
export function CraftBotIntroWidget() {
+ const { startTour } = useTour()
const mascotState = useMascotState()
// This widget's mascot never sleeps: any state whose pose renders the
// sleeping silhouette shows the awake 'resting' pose here instead. Scoped to
@@ -347,6 +349,20 @@ export function CraftBotIntroWidget() {
>
Learn More
+
+ {/* Replay the first-run walkthrough. Hidden at the smallest widget size
+ so it never crowds the mascot + Learn More stack. */}
+ {isEnlarged && (
+ }
+ onClick={() => startTour('core', { restart: true })}
+ style={{ marginTop: 'var(--space-2)' }}
+ >
+ Take a tour
+
+ )}
)
}
diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx
index ed8ff167..23db3842 100644
--- a/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx
+++ b/app/ui_layer/browser/frontend/src/pages/Settings/GeneralSettings.tsx
@@ -13,6 +13,7 @@ import {
Trash2,
Package,
PackageOpen,
+ Compass,
} from 'lucide-react'
import {
Button,
@@ -26,6 +27,7 @@ import {
} from '../../components/ui'
import { useTheme } from '../../contexts/ThemeContext'
import { useWebSocket } from '../../contexts/WebSocketContext'
+import { useTour } from '../../tour'
import { useConfirmModal } from '../../hooks'
import styles from './SettingsPage.module.css'
import { useSettingsWebSocket } from './useSettingsWebSocket'
@@ -73,6 +75,7 @@ function getInitialAgentName(): string {
export function GeneralSettings() {
const { send, onMessage, isConnected } = useSettingsWebSocket()
const { agentProfilePictureUrl, agentProfilePictureHasCustom } = useWebSocket()
+ const { startTour } = useTour()
const version = useAppSelector(selectVersion)
const dispatch = useAppDispatch()
const { theme: globalTheme, setTheme: setGlobalTheme } = useTheme()
@@ -713,6 +716,22 @@ export function GeneralSettings() {
System
+
+
+
Product Tour
+
+ }
+ onClick={() => startTour('core', { restart: true })}
+ >
+ Take the tour
+
+
+
+ Replay the guided walkthrough of the CraftBot interface.
+
+
diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx
index 2d0aebb5..7da5bd3e 100644
--- a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx
+++ b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.tsx
@@ -1,6 +1,14 @@
import { useState } from 'react'
import styles from './SettingsPage.module.css'
+import { tourAnchorProps, useTourEnvAction, type TourAnchorId } from '../../tour'
import { SettingsCategory, categories } from './types'
+
+// Settings tabs the guided tour highlights individually.
+const TAB_TOUR_ANCHORS: Partial
> = {
+ proactive: 'settings-proactive',
+ skills: 'settings-skills',
+ integrations: 'settings-integrations',
+}
import { GeneralSettings } from './GeneralSettings'
import { ProactiveSettings } from './ProactiveSettings'
import { MemorySettings } from './MemorySettings'
@@ -13,6 +21,14 @@ import { LivingUISettings } from './LivingUISettings'
export function SettingsPage() {
const [activeCategory, setActiveCategory] = useState('general')
+ // Let the guided tour open a specific tab so its panel is shown, not just its
+ // rail button highlighted.
+ useTourEnvAction('openSettingsTab', (arg) => {
+ if (arg && categories.some(c => c.id === arg)) {
+ setActiveCategory(arg as SettingsCategory)
+ }
+ })
+
const renderSettingsContent = () => {
switch (activeCategory) {
case 'general':
@@ -41,17 +57,21 @@ export function SettingsPage() {
{/* Category rail — sits flush against the content, no separate
background/border. Compact icon + label, no description/chevron. */}
-
- {categories.map(cat => (
-
setActiveCategory(cat.id)}
- >
- {cat.icon}
- {cat.label}
-
- ))}
+
+ {categories.map(cat => {
+ const tourAnchor = TAB_TOUR_ANCHORS[cat.id]
+ return (
+ setActiveCategory(cat.id)}
+ {...(tourAnchor ? tourAnchorProps(tourAnchor) : {})}
+ >
+ {cat.icon}
+ {cat.label}
+
+ )
+ })}
diff --git a/app/ui_layer/browser/frontend/src/tour/TourProvider.tsx b/app/ui_layer/browser/frontend/src/tour/TourProvider.tsx
new file mode 100644
index 00000000..8df69dee
--- /dev/null
+++ b/app/ui_layer/browser/frontend/src/tour/TourProvider.tsx
@@ -0,0 +1,138 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react'
+import { useLocation, useNavigate } from 'react-router-dom'
+import { TourController, type TourEnvironment } from './controller'
+import type { TourEnvActionId, TourId } from './types'
+import { TOURS } from './tours'
+import { hasCompletedTour, resetTourCompletion } from './storage'
+import 'driver.js/dist/driver.css'
+import './tour.css'
+
+interface TourContextValue {
+ /** Start a tour now. `restart: true` clears its completed flag first. */
+ startTour: (id: TourId, opts?: { restart?: boolean }) => void
+ /**
+ * Register a component capability the tour can invoke by name (e.g. a layout
+ * expanding its sidebar). Returns an unregister function. Prefer the
+ * `useTourEnvAction` hook, which wires cleanup automatically.
+ */
+ registerEnvAction: (id: TourEnvActionId, fn: (arg?: string) => void) => () => void
+ isActive: boolean
+}
+
+const TourContext = createContext
(null)
+
+// Delay before a first-run tour auto-starts, letting the initial layout,
+// fonts, and websocket-driven content settle so anchors are in place.
+const AUTOSTART_DELAY_MS = 800
+
+interface TourProviderProps {
+ children: ReactNode
+ /**
+ * Gate for the one-time auto-start. The provider only auto-starts the core
+ * tour when true — pass it once the app is past hard onboarding and ready.
+ */
+ autoStartEnabled?: boolean
+}
+
+export function TourProvider({ children, autoStartEnabled = false }: TourProviderProps) {
+ const navigate = useNavigate()
+ const location = useLocation()
+
+ // Latest pathname, readable synchronously from controller callbacks.
+ const pathnameRef = useRef(location.pathname)
+ pathnameRef.current = location.pathname
+
+ // Component capabilities the tour can invoke (see registerEnvAction).
+ const envActionsRef = useRef void>>(new Map())
+
+ const controllerRef = useRef(null)
+ const [isActive, setIsActive] = useState(false)
+ const autoStartedRef = useRef(false)
+
+ const registerEnvAction = useCallback((id: TourEnvActionId, fn: (arg?: string) => void) => {
+ envActionsRef.current.set(id, fn)
+ return () => {
+ // Only remove if still the same fn, so a newer registration isn't clobbered.
+ if (envActionsRef.current.get(id) === fn) {
+ envActionsRef.current.delete(id)
+ }
+ }
+ }, [])
+
+ const environment = useMemo(() => ({
+ navigate: (path: string) => navigate(path),
+ getPathname: () => pathnameRef.current,
+ runEnvAction: (id: TourEnvActionId, arg?: string) => {
+ envActionsRef.current.get(id)?.(arg)
+ },
+ }), [navigate])
+
+ const startTour = useCallback((id: TourId, opts?: { restart?: boolean }) => {
+ const def = TOURS[id]
+ if (!def) return
+ if (controllerRef.current?.isActive()) return // never run two tours at once
+ if (opts?.restart) resetTourCompletion(id)
+ const controller = new TourController(def, environment, () => {
+ controllerRef.current = null
+ setIsActive(false)
+ })
+ controllerRef.current = controller
+ setIsActive(true)
+ void controller.start()
+ }, [environment])
+
+ // One-time auto-start of the core tour for first-time users. autoStartedRef
+ // is set only when the timer actually fires, so StrictMode's mount/cleanup/
+ // remount in dev reschedules cleanly instead of cancelling itself.
+ useEffect(() => {
+ if (!autoStartEnabled || autoStartedRef.current) return
+ const def = TOURS.core
+ if (!def.autoStart || hasCompletedTour('core')) return
+ const timer = window.setTimeout(() => {
+ autoStartedRef.current = true
+ startTour('core')
+ }, AUTOSTART_DELAY_MS)
+ return () => window.clearTimeout(timer)
+ }, [autoStartEnabled, startTour])
+
+ // Tear down an in-flight tour if the provider unmounts.
+ useEffect(() => () => {
+ controllerRef.current?.destroy()
+ controllerRef.current = null
+ }, [])
+
+ const value = useMemo(() => ({
+ startTour,
+ registerEnvAction,
+ isActive,
+ }), [startTour, registerEnvAction, isActive])
+
+ return {children}
+}
+
+export function useTour(): TourContextValue {
+ const ctx = useContext(TourContext)
+ if (!ctx) throw new Error('useTour must be used within a TourProvider')
+ return ctx
+}
+
+/**
+ * Register a component capability the tour can invoke by name (e.g. a layout
+ * expanding its sidebar so a nav item is visible). The latest `fn` is always
+ * used, and it unregisters automatically on unmount.
+ */
+export function useTourEnvAction(id: TourEnvActionId, fn: (arg?: string) => void): void {
+ const { registerEnvAction } = useTour()
+ const fnRef = useRef(fn)
+ fnRef.current = fn
+ useEffect(() => registerEnvAction(id, (arg) => fnRef.current(arg)), [id, registerEnvAction])
+}
diff --git a/app/ui_layer/browser/frontend/src/tour/anchors.ts b/app/ui_layer/browser/frontend/src/tour/anchors.ts
new file mode 100644
index 00000000..8fc80b63
--- /dev/null
+++ b/app/ui_layer/browser/frontend/src/tour/anchors.ts
@@ -0,0 +1,43 @@
+// Stable DOM anchors for the guided product tour.
+//
+// Component CSS is authored with CSS Modules, whose class names are hashed at
+// build time and therefore useless as tour targets. Instead, tour targets are
+// explicit `data-tour=""` attributes. The same typed id is referenced by
+// the component (via `tourAnchorProps`) and by the step definition (via
+// `tourSelector`), so renaming an anchor is a compile error rather than a
+// silently broken step.
+
+export type TourAnchorId =
+ | 'chat-composer'
+ | 'chat-plus'
+ | 'nav-new-chat'
+ | 'nav-chats'
+ | 'nav-main-session'
+ | 'nav-living-ui'
+ // Tabs inside the "Add Living UI" modal.
+ | 'livingui-tab-marketplace'
+ | 'livingui-tab-custom'
+ | 'livingui-tab-import'
+ | 'nav-dashboard'
+ | 'nav-workspace'
+ // On-page anchors for the Settings page: the whole category rail, plus the
+ // individual tabs the tour calls out.
+ | 'settings-categories'
+ | 'settings-proactive'
+ | 'settings-skills'
+ | 'settings-integrations'
+
+const ATTR = 'data-tour' as const
+
+/**
+ * Props to spread onto the JSX element a tour step should highlight:
+ *
+ */
+export function tourAnchorProps(id: TourAnchorId): { 'data-tour': TourAnchorId } {
+ return { [ATTR]: id }
+}
+
+/** CSS selector the tour controller hands to driver.js to locate the anchor. */
+export function tourSelector(id: TourAnchorId): string {
+ return `[${ATTR}="${id}"]`
+}
diff --git a/app/ui_layer/browser/frontend/src/tour/controller.ts b/app/ui_layer/browser/frontend/src/tour/controller.ts
new file mode 100644
index 00000000..f8f80120
--- /dev/null
+++ b/app/ui_layer/browser/frontend/src/tour/controller.ts
@@ -0,0 +1,215 @@
+import { driver, type Config, type DriveStep, type Driver } from 'driver.js'
+import type { TourDefinition, TourEnvActionId, TourStep } from './types'
+import { tourSelector } from './anchors'
+import { markTourCompleted } from './storage'
+
+// How long to wait for a step's anchor to mount (after navigation and any
+// environment actions) before giving up and skipping the step.
+const ELEMENT_WAIT_MS = 4000
+const ELEMENT_POLL_MS = 50
+
+/**
+ * The bridge between the framework-agnostic controller and the React app.
+ * Supplied by TourProvider so the controller never imports React or router.
+ */
+export interface TourEnvironment {
+ /** Navigate the SPA to a path (react-router navigate). */
+ navigate: (path: string) => void
+ /** Current pathname, read fresh on each call. */
+ getPathname: () => string
+ /** Invoke a named environment action (with an optional argument) if registered. */
+ runEnvAction: (id: TourEnvActionId, arg?: string) => void
+}
+
+type Direction = 1 | -1
+
+/**
+ * Wait for `selector` to resolve to a laid-out element, polling on animation
+ * frames. Resolves the element, or null on timeout / cancellation. Cancelable
+ * so a torn-down tour stops polling immediately.
+ */
+function waitForElement(
+ selector: string,
+ timeoutMs: number,
+ isCancelled: () => boolean,
+): Promise {
+ return new Promise(resolve => {
+ const start = performance.now()
+ const tick = () => {
+ if (isCancelled()) return resolve(null)
+ const el = document.querySelector(selector)
+ // getClientRects() is empty for display:none / not-yet-laid-out nodes,
+ // but non-empty for position:fixed elements (unlike offsetParent), so it
+ // works for the mobile sidebar drawer too.
+ if (el && el.getClientRects().length > 0) return resolve(el)
+ if (performance.now() - start >= timeoutMs) return resolve(null)
+ window.setTimeout(() => requestAnimationFrame(tick), ELEMENT_POLL_MS)
+ }
+ requestAnimationFrame(tick)
+ })
+}
+
+/**
+ * Drives a single tour over driver.js. Owns exactly one driver instance and
+ * all cross-route / cross-state transition logic. Framework-agnostic: it talks
+ * to the app only through the injected TourEnvironment.
+ */
+export class TourController {
+ private readonly def: TourDefinition
+ private readonly env: TourEnvironment
+ private readonly onExit: () => void
+
+ private driverObj: Driver | null = null
+ private stepIndex = 0
+ private transitioning = false
+ private cancelled = false
+ private finished = false
+
+ constructor(def: TourDefinition, env: TourEnvironment, onExit: () => void) {
+ this.def = def
+ this.env = env
+ this.onExit = onExit
+ }
+
+ isActive(): boolean {
+ return this.driverObj?.isActive() ?? false
+ }
+
+ /** Start the tour at its first showable step. */
+ async start(): Promise {
+ if (this.driverObj || this.finished) return
+ this.driverObj = driver(this.buildConfig())
+ const first = await this.resolveFrom(0, 1)
+ if (this.finished || !this.driverObj) return
+ if (first === null) {
+ // No anchors resolved at all — abort quietly without marking complete.
+ this.teardown(false)
+ return
+ }
+ this.stepIndex = first
+ this.driverObj.drive(first)
+ }
+
+ /** Tear down without marking complete (e.g. the provider unmounted). */
+ destroy(): void {
+ this.teardown(false)
+ }
+
+ private buildConfig(): Config {
+ return {
+ steps: this.def.steps.map(step => this.toDriveStep(step)),
+ animate: true,
+ overlayColor: '#000',
+ overlayOpacity: 0.6,
+ stagePadding: 6,
+ stageRadius: 8,
+ smoothScroll: true,
+ allowClose: true,
+ // A read-only walkthrough: the highlighted element is not clickable, so a
+ // user can't derail the tour by acting on it mid-step.
+ disableActiveInteraction: true,
+ popoverClass: 'cb-tour',
+ showProgress: true,
+ progressText: '{{current}} of {{total}}',
+ showButtons: ['next', 'previous', 'close'],
+ nextBtnText: 'Next',
+ prevBtnText: 'Back',
+ doneBtnText: 'Done',
+ // We own all navigation between steps, so intercept the buttons and drive
+ // the transition ourselves.
+ onNextClick: () => { void this.advance(1) },
+ onPrevClick: () => { void this.advance(-1) },
+ // Fires for every teardown path the user initiates (X, Esc, overlay, and
+ // Done on the last step) — the single place we record completion.
+ onDestroyStarted: () => { this.teardown(true) },
+ }
+ }
+
+ private toDriveStep(step: TourStep): DriveStep {
+ return {
+ element: step.anchor ? tourSelector(step.anchor) : undefined,
+ popover: {
+ title: step.popover.title,
+ description: step.popover.description,
+ side: step.popover.side,
+ align: step.popover.align,
+ },
+ }
+ }
+
+ private async advance(direction: Direction): Promise {
+ if (!this.driverObj || this.transitioning || this.finished) return
+ const target = this.stepIndex + direction
+ if (target < 0) return
+ if (target >= this.def.steps.length) {
+ // Advanced past the last step ("Done") — complete the tour.
+ this.teardown(true)
+ return
+ }
+ this.transitioning = true
+ try {
+ const resolved = await this.resolveFrom(target, direction)
+ if (this.finished || !this.driverObj) return
+ if (resolved === null) {
+ // Nothing further to show in this direction. Forward means the tour is
+ // effectively over; backward simply stays on the current step.
+ if (direction === 1) this.teardown(true)
+ return
+ }
+ this.stepIndex = resolved
+ this.driverObj.moveTo(resolved)
+ } finally {
+ this.transitioning = false
+ }
+ }
+
+ /**
+ * Scan from `startIndex` in `direction` for the first step that can actually
+ * be shown, preparing each candidate as it goes: run its environment actions,
+ * navigate to its route, then wait for its anchor to mount. Steps whose
+ * anchor never appears (e.g. a feature is disabled) are skipped. Modal steps
+ * (no anchor) always resolve. Returns the resolved index, or null if none.
+ */
+ private async resolveFrom(startIndex: number, direction: Direction): Promise {
+ let idx = startIndex
+ while (idx >= 0 && idx < this.def.steps.length) {
+ const step = this.def.steps[idx]
+
+ step.env?.forEach(action =>
+ typeof action === 'string'
+ ? this.env.runEnvAction(action)
+ : this.env.runEnvAction(action.id, action.arg),
+ )
+
+ if (step.route && this.env.getPathname() !== step.route) {
+ this.env.navigate(step.route)
+ }
+
+ if (!step.anchor) return idx
+
+ const el = await waitForElement(
+ tourSelector(step.anchor),
+ ELEMENT_WAIT_MS,
+ () => this.cancelled,
+ )
+ if (this.cancelled) return null
+ if (el) return idx
+
+ idx += direction
+ }
+ return null
+ }
+
+ private teardown(markComplete: boolean): void {
+ if (this.finished) return
+ this.finished = true
+ this.cancelled = true
+ if (markComplete) markTourCompleted(this.def.id)
+ const d = this.driverObj
+ this.driverObj = null
+ // driver.destroy() is the low-level teardown and does not re-enter
+ // onDestroyStarted, so this is safe to call from within that hook.
+ if (d?.isActive()) d.destroy()
+ this.onExit()
+ }
+}
diff --git a/app/ui_layer/browser/frontend/src/tour/index.ts b/app/ui_layer/browser/frontend/src/tour/index.ts
new file mode 100644
index 00000000..25135a01
--- /dev/null
+++ b/app/ui_layer/browser/frontend/src/tour/index.ts
@@ -0,0 +1,5 @@
+// Public surface of the guided-tour module. Components import from here.
+export { TourProvider, useTour, useTourEnvAction } from './TourProvider'
+export { tourAnchorProps } from './anchors'
+export type { TourAnchorId } from './anchors'
+export type { TourId, TourEnvActionId } from './types'
diff --git a/app/ui_layer/browser/frontend/src/tour/storage.ts b/app/ui_layer/browser/frontend/src/tour/storage.ts
new file mode 100644
index 00000000..b882f3b7
--- /dev/null
+++ b/app/ui_layer/browser/frontend/src/tour/storage.ts
@@ -0,0 +1,30 @@
+import type { TourId } from './types'
+
+// Device-local record of which tours a user has already seen. Follows the
+// existing `craftbot.*` localStorage convention (see Layout.tsx). A completed
+// tour never auto-starts again, but can always be replayed on demand.
+const KEY_PREFIX = 'craftbot.tour.completed.'
+
+export function hasCompletedTour(id: TourId): boolean {
+ try {
+ return window.localStorage.getItem(KEY_PREFIX + id) === '1'
+ } catch {
+ return false
+ }
+}
+
+export function markTourCompleted(id: TourId): void {
+ try {
+ window.localStorage.setItem(KEY_PREFIX + id, '1')
+ } catch {
+ /* storage unavailable — the tour may simply reappear next session */
+ }
+}
+
+export function resetTourCompletion(id: TourId): void {
+ try {
+ window.localStorage.removeItem(KEY_PREFIX + id)
+ } catch {
+ /* no-op */
+ }
+}
diff --git a/app/ui_layer/browser/frontend/src/tour/tour.css b/app/ui_layer/browser/frontend/src/tour/tour.css
new file mode 100644
index 00000000..e1b6c1d7
--- /dev/null
+++ b/app/ui_layer/browser/frontend/src/tour/tour.css
@@ -0,0 +1,123 @@
+/* Theme-aware overrides for the driver.js popover.
+ *
+ * Base styles come from driver.js/dist/driver.css (imported before this file
+ * in TourProvider). Everything here is scoped to `.cb-tour` (our popoverClass)
+ * and uses CraftBot design tokens, so the tour matches both light and dark
+ * themes automatically — the tokens flip with [data-theme]. */
+
+.driver-popover.cb-tour {
+ background-color: var(--bg-elevated);
+ color: var(--text-primary);
+ border: 1px solid var(--border-primary);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ padding: var(--space-4);
+ min-width: 260px;
+ max-width: 340px;
+ font-family: var(--font-sans);
+}
+
+.driver-popover.cb-tour .driver-popover-title {
+ font-size: var(--text-lg);
+ font-weight: var(--font-semibold);
+ line-height: var(--leading-tight);
+ color: var(--text-primary);
+ /* Leave room for the close button so a long title never runs under it. */
+ padding-right: var(--space-5);
+}
+
+.driver-popover.cb-tour .driver-popover-description {
+ font-size: var(--text-sm);
+ font-weight: var(--font-normal);
+ line-height: var(--leading-normal);
+ color: var(--text-secondary);
+ /* driver.js defaults to a tight 5px under the title; give it more room. */
+ margin-top: var(--space-2);
+}
+
+.driver-popover.cb-tour .driver-popover-progress-text {
+ font-size: var(--text-xs);
+ color: var(--text-tertiary);
+}
+
+.driver-popover.cb-tour .driver-popover-close-btn {
+ /* Base driver.js pins this at top:0/right:0, ignoring the popover padding, so
+ * it floats above the title. Inset it to the padding and center the glyph on
+ * the title's line so the two line up. */
+ top: var(--space-4);
+ right: var(--space-4);
+ width: 20px;
+ height: 20px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ line-height: 1;
+ color: var(--text-tertiary);
+ transition: color var(--transition-fast);
+}
+.driver-popover.cb-tour .driver-popover-close-btn:hover,
+.driver-popover.cb-tour .driver-popover-close-btn:focus {
+ color: var(--text-primary);
+}
+
+.driver-popover.cb-tour .driver-popover-footer {
+ margin-top: var(--space-4);
+ gap: var(--space-2);
+}
+
+/* Neutral base for every footer button. */
+.driver-popover.cb-tour .driver-popover-footer-btn {
+ font-family: var(--font-sans);
+ font-size: var(--text-sm);
+ font-weight: var(--font-medium);
+ line-height: var(--leading-tight);
+ border-radius: var(--radius-md);
+ padding: var(--space-1) var(--space-3);
+ text-shadow: none;
+ transition:
+ background-color var(--transition-fast),
+ color var(--transition-fast),
+ border-color var(--transition-fast);
+}
+
+/* Secondary "Back" button. */
+.driver-popover.cb-tour .driver-popover-prev-btn {
+ background-color: transparent;
+ color: var(--text-secondary);
+ border: 1px solid var(--border-secondary);
+}
+.driver-popover.cb-tour .driver-popover-prev-btn:hover:not(.driver-popover-btn-disabled) {
+ background-color: var(--bg-hover);
+ color: var(--text-primary);
+ border-color: var(--border-hover);
+}
+
+/* Primary "Next" / "Done" button — the one accent, matching the design system's
+ * sparing use of brand orange for primary CTAs. */
+.driver-popover.cb-tour .driver-popover-next-btn,
+.driver-popover.cb-tour .driver-popover-done-btn {
+ background-color: var(--color-primary);
+ color: #fff;
+ border: 1px solid var(--color-primary);
+}
+.driver-popover.cb-tour .driver-popover-next-btn:hover,
+.driver-popover.cb-tour .driver-popover-done-btn:hover {
+ background-color: var(--color-primary-hover);
+ border-color: var(--color-primary-hover);
+}
+
+/* Arrow: recolor only the border that points toward the target so it matches
+ * the popover surface. Scoped selectors outweigh driver's base rule. */
+.driver-popover.cb-tour .driver-popover-arrow-side-left {
+ border-left-color: var(--bg-elevated);
+}
+.driver-popover.cb-tour .driver-popover-arrow-side-right {
+ border-right-color: var(--bg-elevated);
+}
+.driver-popover.cb-tour .driver-popover-arrow-side-top {
+ border-top-color: var(--bg-elevated);
+}
+.driver-popover.cb-tour .driver-popover-arrow-side-bottom {
+ border-bottom-color: var(--bg-elevated);
+}
diff --git a/app/ui_layer/browser/frontend/src/tour/tours/core.ts b/app/ui_layer/browser/frontend/src/tour/tours/core.ts
new file mode 100644
index 00000000..62418a91
--- /dev/null
+++ b/app/ui_layer/browser/frontend/src/tour/tours/core.ts
@@ -0,0 +1,188 @@
+import type { TourDefinition } from '../types'
+
+// The first-run orientation tour. This is pure data: adding, reordering, or
+// rewording a step is a one-line edit here with no engine changes. Keep it
+// short (orientation, not documentation) — deeper surfaces are better served
+// by their own contextual mini-tours added to the registry later.
+export const coreTour: TourDefinition = {
+ id: 'core',
+ autoStart: true,
+ steps: [
+ {
+ // Opens a fresh New Chat first, so the whole walkthrough runs on a clean
+ // draft session rather than the user's persistent Main session.
+ id: 'welcome',
+ env: ['openNewChat'],
+ popover: {
+ title: 'Welcome to CraftBot',
+ description:
+ 'Here is a quick tour of the essentials. It takes about a minute.',
+ },
+ },
+ {
+ id: 'chat-composer',
+ anchor: 'chat-composer',
+ popover: {
+ title: 'Talk to CraftBot',
+ description:
+ 'Type anything here: a question, a task, attach files, or a whole project. Communicate with CraftBot like you would with human over text messages.',
+ side: 'top',
+ align: 'center',
+ },
+ },
+ {
+ // Opens the Main chat view, then highlights its pinned sidebar row (which
+ // carries the "Why is Main different?" info tooltip) to explain it.
+ id: 'main-session',
+ route: '/',
+ anchor: 'nav-main-session',
+ env: ['ensureSidebarVisible', 'ensureChatsExpanded'],
+ popover: {
+ title: 'Your Main chat',
+ description:
+ "Main is the agent's home chat: it can't be deleted or renamed, and anything that happens on its own (like scheduled tasks or updates from connected apps) arrives here.",
+ side: 'right',
+ align: 'start',
+ },
+ },
+ {
+ id: 'living-ui',
+ anchor: 'nav-living-ui',
+ env: ['ensureSidebarVisible', 'closeLivingUIModal'],
+ popover: {
+ title: 'Living UI apps',
+ description:
+ 'Ask CraftBot to build you a real app (a tracker, a CRM, a dashboard) and it appears here, built and running. There are three ways to add one:',
+ side: 'right',
+ align: 'start',
+ },
+ },
+ // Open the "Add Living UI" modal and walk its three creation methods, one
+ // per tab. The modal is closed again by the Dashboard step below.
+ {
+ id: 'living-ui-marketplace',
+ env: ['openLivingUIModal', { id: 'openLivingUITab', arg: 'marketplace' }],
+ anchor: 'livingui-tab-marketplace',
+ popover: {
+ title: 'Marketplace',
+ description:
+ 'Install a ready-made app from the community marketplace with a single click.',
+ side: 'bottom',
+ align: 'start',
+ },
+ },
+ {
+ id: 'living-ui-custom',
+ env: ['openLivingUIModal', { id: 'openLivingUITab', arg: 'custom' }],
+ anchor: 'livingui-tab-custom',
+ popover: {
+ title: 'Create Custom',
+ description:
+ 'Describe what you want and the agent builds it: configure a few basics, answer a short interview, then it writes the spec and builds the app.',
+ side: 'bottom',
+ align: 'center',
+ },
+ },
+ {
+ id: 'living-ui-import',
+ env: ['openLivingUIModal', { id: 'openLivingUITab', arg: 'import' }],
+ anchor: 'livingui-tab-import',
+ popover: {
+ title: 'Import',
+ description:
+ 'Bring in an existing Living UI from a ZIP, a folder, or a git URL.',
+ side: 'bottom',
+ align: 'end',
+ },
+ },
+ // These steps open a destination and show the real page. Dashboard and
+ // Workspace also highlight their sidebar button; Settings highlights an
+ // element on the page itself.
+ {
+ id: 'dashboard',
+ route: '/dashboard',
+ anchor: 'nav-dashboard',
+ env: ['closeLivingUIModal', 'ensureSidebarVisible'],
+ popover: {
+ title: 'CraftBot Dashboard',
+ description:
+ 'A live control room for CraftBot, tracking usage, activity, and system health.',
+ side: 'right',
+ align: 'start',
+ },
+ },
+ {
+ id: 'workspace',
+ route: '/workspace',
+ anchor: 'nav-workspace',
+ env: ['ensureSidebarVisible'],
+ popover: {
+ title: 'CraftBot workspace',
+ description: "CraftBot's dedicated file system. Browse the files your agent reads and writes, and upload your own.",
+ side: 'right',
+ align: 'start',
+ },
+ },
+ {
+ id: 'settings',
+ route: '/settings',
+ anchor: 'settings-categories',
+ popover: {
+ title: 'Settings',
+ description:
+ 'Configure your agent here. A few areas worth knowing:',
+ side: 'right',
+ align: 'start',
+ },
+ },
+ {
+ id: 'settings-proactive',
+ route: '/settings',
+ anchor: 'settings-proactive',
+ env: [{ id: 'openSettingsTab', arg: 'proactive' }],
+ popover: {
+ title: 'Proactive',
+ description:
+ 'Let your agent work on its own: run scheduled tasks and react to events without being asked.',
+ side: 'right',
+ align: 'center',
+ },
+ },
+ {
+ id: 'settings-skills',
+ route: '/settings',
+ anchor: 'settings-skills',
+ env: [{ id: 'openSettingsTab', arg: 'skills' }],
+ popover: {
+ title: 'Skills',
+ description:
+ 'Add reusable capabilities so your agent knows how to carry out specific tasks.',
+ side: 'right',
+ align: 'center',
+ },
+ },
+ {
+ id: 'settings-integrations',
+ route: '/settings',
+ anchor: 'settings-integrations',
+ env: [{ id: 'openSettingsTab', arg: 'integrations' }],
+ popover: {
+ title: 'Integrations',
+ description:
+ 'Connect apps like Gmail, Calendar, and Notion so your agent can work across them.',
+ side: 'right',
+ align: 'center',
+ },
+ },
+ {
+ // Return to a fresh New Chat so the user lands ready to start working.
+ id: 'done',
+ env: ['openNewChat'],
+ popover: {
+ title: "You're all set",
+ description:
+ 'That is the end of this tour. Start giving CraftBot tasks to work for you.',
+ },
+ },
+ ],
+}
diff --git a/app/ui_layer/browser/frontend/src/tour/tours/index.ts b/app/ui_layer/browser/frontend/src/tour/tours/index.ts
new file mode 100644
index 00000000..73b78471
--- /dev/null
+++ b/app/ui_layer/browser/frontend/src/tour/tours/index.ts
@@ -0,0 +1,8 @@
+import type { TourDefinition, TourId } from '../types'
+import { coreTour } from './core'
+
+// Registry of every tour the app can run. Add a new mini-tour by dropping a
+// definition file beside core.ts and registering it here — no engine changes.
+export const TOURS: Record = {
+ core: coreTour,
+}
diff --git a/app/ui_layer/browser/frontend/src/tour/types.ts b/app/ui_layer/browser/frontend/src/tour/types.ts
new file mode 100644
index 00000000..d70d685e
--- /dev/null
+++ b/app/ui_layer/browser/frontend/src/tour/types.ts
@@ -0,0 +1,50 @@
+import type { Side, Alignment } from 'driver.js'
+import type { TourAnchorId } from './anchors'
+
+// Imperative capabilities a step can ask the app to perform before it is
+// shown: expanding the sidebar so a nav item is on screen, opening a fresh New
+// Chat so the chat is demonstrated on a clean draft rather than the Main
+// session, or expanding the Chats group so the pinned Main row is visible. The
+// owning component registers the implementation (see `useTourEnvAction`); a
+// step only names the capability, keeping the tour decoupled from internals.
+export type TourEnvActionId =
+ | 'ensureSidebarVisible'
+ | 'openNewChat'
+ | 'ensureChatsExpanded'
+ | 'openSettingsTab'
+ | 'openLivingUIModal'
+ | 'closeLivingUIModal'
+ | 'openLivingUITab'
+
+// A step's environment entry: an action id on its own, or that id paired with a
+// string argument (e.g. which Settings tab to open).
+export type TourEnvAction = TourEnvActionId | { id: TourEnvActionId; arg: string }
+
+export interface TourStep {
+ /** Stable id for debugging/analytics. Not shown to the user. */
+ id: string
+ /** Element to highlight. Omit for a centered modal step (welcome / done). */
+ anchor?: TourAnchorId
+ /**
+ * Route the app must be on before the step is shown. The controller
+ * navigates here first if needed, then waits for the anchor to mount.
+ */
+ route?: string
+ /** Environment actions to run before highlighting. Must be idempotent. */
+ env?: TourEnvAction[]
+ popover: {
+ title: string
+ description: string
+ side?: Side
+ align?: Alignment
+ }
+}
+
+export type TourId = 'core'
+
+export interface TourDefinition {
+ id: TourId
+ /** When true, the tour auto-starts once for a first-time user. */
+ autoStart: boolean
+ steps: TourStep[]
+}