From 66b68ea4eb23a4907125ecdb85183dd7ddedecf4 Mon Sep 17 00:00:00 2001 From: Shirone Date: Sun, 11 Jan 2026 12:49:29 +0100 Subject: [PATCH 1/2] feat: implement onboarding wizard for board view - Added a new onboarding wizard to guide users through the board features. - Integrated sample feature generation for quick start onboarding. - Enhanced BoardView component to manage onboarding state and actions. - Updated BoardControls and BoardHeader to include tour functionality. - Introduced utility functions for sample feature management in constants. - Improved user experience with toast notifications for onboarding actions. --- apps/ui/src/components/views/board-view.tsx | 102 ++- .../views/board-view/board-controls.tsx | 29 +- .../views/board-view/board-header.tsx | 7 + .../components/board-onboarding-wizard.tsx | 678 ++++++++++++++++++ .../views/board-view/components/index.ts | 1 + .../components/views/board-view/constants.ts | 114 +++ .../views/board-view/hooks/index.ts | 1 + .../board-view/hooks/use-board-onboarding.ts | 409 +++++++++++ 8 files changed, 1339 insertions(+), 2 deletions(-) create mode 100644 apps/ui/src/components/views/board-view/components/board-onboarding-wizard.tsx create mode 100644 apps/ui/src/components/views/board-view/hooks/use-board-onboarding.ts diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 2b1e35913..054314455 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -72,9 +72,11 @@ import { useBoardPersistence, useFollowUpState, useSelectionMode, + useBoardOnboarding, } from './board-view/hooks'; -import { SelectionActionBar } from './board-view/components'; +import { SelectionActionBar, BoardOnboardingWizard } from './board-view/components'; import { MassEditDialog } from './board-view/dialogs'; +import { generateSampleFeatures, isSampleFeature } from './board-view/constants'; // Stable empty array to avoid infinite loop in selector const EMPTY_WORKTREES: ReturnType['getWorktrees']> = []; @@ -186,6 +188,8 @@ export function BoardView() { const [searchQuery, setSearchQuery] = useState(''); // Plan approval loading state const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false); + // Quick start loading state for onboarding + const [isQuickStartLoading, setIsQuickStartLoading] = useState(false); // Derive spec creation state from store - check if current project is the one being created const isCreatingSpec = specCreatingForProject === currentProject?.path; const creatingSpecProjectPath = specCreatingForProject ?? undefined; @@ -1028,6 +1032,84 @@ export function BoardView() { currentProject, }); + // Use onboarding wizard hook - check if board is empty (no non-sample features) + const nonSampleFeatureCount = useMemo( + () => hookFeatures.filter((f) => !isSampleFeature(f)).length, + [hookFeatures] + ); + const onboarding = useBoardOnboarding({ + projectPath: currentProject?.path || null, + isEmpty: nonSampleFeatureCount === 0 && !isLoading, + totalFeatureCount: hookFeatures.length, + // Don't show wizard when spec generation is happening (for new projects) + isSpecDialogOpen: isCreatingSpec, + }); + + // Handler for Quick Start - create sample features + const handleQuickStart = useCallback(async () => { + if (!currentProject) return; + + setIsQuickStartLoading(true); + try { + const api = getHttpApiClient(); + const sampleFeatures = generateSampleFeatures(); + + // Create each sample feature + for (const featureData of sampleFeatures) { + const result = await api.features.create(currentProject.path, featureData); + if (result.success && result.feature) { + useAppStore.getState().addFeature(result.feature); + } + } + + onboarding.markQuickStartUsed(); + toast.success('Sample tasks added!', { + description: 'Explore the board to see tasks at different stages.', + }); + + // Reload features to ensure state is in sync + loadFeatures(); + } catch (error) { + logger.error('Failed to create sample features:', error); + toast.error('Failed to add sample tasks'); + } finally { + setIsQuickStartLoading(false); + } + }, [currentProject, loadFeatures, onboarding]); + + // Handler for clearing sample data + const handleClearSampleData = useCallback(async () => { + if (!currentProject) return; + + const sampleFeatures = hookFeatures.filter((f) => isSampleFeature(f)); + if (sampleFeatures.length === 0) { + onboarding.setHasSampleData(false); + return; + } + + try { + const api = getHttpApiClient(); + const featureIds = sampleFeatures.map((f) => f.id); + const result = await api.features.bulkDelete(currentProject.path, featureIds); + + if (result.success || (result.results && result.results.some((r) => r.success))) { + // Remove from local state + const successfullyDeletedIds = + result.results?.filter((r) => r.success).map((r) => r.featureId) ?? featureIds; + successfullyDeletedIds.forEach((id) => { + useAppStore.getState().removeFeature(id); + }); + + onboarding.setHasSampleData(false); + toast.success('Sample tasks removed'); + loadFeatures(); + } + } catch (error) { + logger.error('Failed to clear sample data:', error); + toast.error('Failed to remove sample tasks'); + } + }, [currentProject, hookFeatures, loadFeatures, onboarding]); + // Find feature for pending plan approval const pendingApprovalFeature = useMemo(() => { if (!pendingPlanApproval) return null; @@ -1210,6 +1292,8 @@ export function BoardView() { onShowBoardBackground={() => setShowBoardBackgroundModal(true)} onShowCompletedModal={() => setShowCompletedModal(true)} completedCount={completedFeatures.length} + onShowTour={onboarding.retriggerWizard} + canShowTour={onboarding.canRetrigger} /> {/* Worktree Panel - conditionally rendered based on visibility setting */} @@ -1568,6 +1652,22 @@ export function BoardView() { setSelectedWorktreeForAction(null); }} /> + + {/* Board Onboarding Wizard */} + ); } diff --git a/apps/ui/src/components/views/board-view/board-controls.tsx b/apps/ui/src/components/views/board-view/board-controls.tsx index c4d7f3af8..dacb38b6d 100644 --- a/apps/ui/src/components/views/board-view/board-controls.tsx +++ b/apps/ui/src/components/views/board-view/board-controls.tsx @@ -1,12 +1,16 @@ import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { ImageIcon, Archive } from 'lucide-react'; +import { ImageIcon, Archive, HelpCircle } from 'lucide-react'; interface BoardControlsProps { isMounted: boolean; onShowBoardBackground: () => void; onShowCompletedModal: () => void; completedCount: number; + /** Callback to show the onboarding wizard tour */ + onShowTour?: () => void; + /** Whether the tour can be shown (wizard was previously completed/skipped) */ + canShowTour?: boolean; } export function BoardControls({ @@ -14,12 +18,35 @@ export function BoardControls({ onShowBoardBackground, onShowCompletedModal, completedCount, + onShowTour, + canShowTour = false, }: BoardControlsProps) { if (!isMounted) return null; return (
+ {/* Board Tour Button - only show if tour can be retriggered */} + {canShowTour && onShowTour && ( + + + + + +

Take a Board Tour

+
+
+ )} + {/* Board Background Button */} diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 5a9b73029..dc31f66eb 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -31,6 +31,9 @@ interface BoardHeaderProps { onShowBoardBackground: () => void; onShowCompletedModal: () => void; completedCount: number; + // Tour/onboarding props + onShowTour?: () => void; + canShowTour?: boolean; } // Shared styles for header control containers @@ -53,6 +56,8 @@ export function BoardHeader({ onShowBoardBackground, onShowCompletedModal, completedCount, + onShowTour, + canShowTour, }: BoardHeaderProps) { const [showAutoModeSettings, setShowAutoModeSettings] = useState(false); const apiKeys = useAppStore((state) => state.apiKeys); @@ -113,6 +118,8 @@ export function BoardHeader({ onShowBoardBackground={onShowBoardBackground} onShowCompletedModal={onShowCompletedModal} completedCount={completedCount} + onShowTour={onShowTour} + canShowTour={canShowTour} />
diff --git a/apps/ui/src/components/views/board-view/components/board-onboarding-wizard.tsx b/apps/ui/src/components/views/board-view/components/board-onboarding-wizard.tsx new file mode 100644 index 000000000..141399c3f --- /dev/null +++ b/apps/ui/src/components/views/board-view/components/board-onboarding-wizard.tsx @@ -0,0 +1,678 @@ +/** + * Board Onboarding Wizard Component + * + * A multi-step wizard overlay that guides new users through the Kanban board + * workflow with visual highlighting (spotlight effect) on each column. + * + * Features: + * - Spotlight/overlay effect to focus attention on each column + * - Step navigation (Next, Previous, Skip) + * - Quick Start button to generate sample cards + * - Responsive design for mobile, tablet, and desktop + * - Keyboard navigation support + */ + +import { useEffect, useRef, useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + X, + ChevronLeft, + ChevronRight, + Sparkles, + PlayCircle, + Lightbulb, + CheckCircle2, + Trash2, + Loader2, + PartyPopper, + Settings2, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { WIZARD_STEPS, type WizardStep } from '../hooks/use-board-onboarding'; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +/** Threshold for placing tooltip to the right of column (30% of viewport) */ +const TOOLTIP_POSITION_RIGHT_THRESHOLD = 0.3; + +/** Threshold for placing tooltip to the left of column (70% of viewport) */ +const TOOLTIP_POSITION_LEFT_THRESHOLD = 0.7; + +/** Padding around tooltip and highlight elements (px) */ +const SPOTLIGHT_PADDING = 8; + +/** Padding between column and tooltip (px) */ +const TOOLTIP_OFFSET = 16; + +/** Vertical offset from top of column to tooltip (px) */ +const TOOLTIP_TOP_OFFSET = 40; + +/** Maximum tooltip width (px) */ +const TOOLTIP_MAX_WIDTH = 400; + +/** Minimum safe margin from viewport edges (px) */ +const VIEWPORT_SAFE_MARGIN = 16; + +/** Threshold from bottom of viewport to trigger alternate positioning (px) */ +const BOTTOM_THRESHOLD = 450; + +/** Debounce delay for resize handler (ms) */ +const RESIZE_DEBOUNCE_MS = 100; + +/** Animation duration for step transitions (ms) */ +const STEP_TRANSITION_DURATION = 200; + +/** ID for the wizard description element (for aria-describedby) */ +const WIZARD_DESCRIPTION_ID = 'wizard-step-description'; + +/** ID for the wizard title element (for aria-labelledby) */ +const WIZARD_TITLE_ID = 'wizard-step-title'; + +interface BoardOnboardingWizardProps { + isVisible: boolean; + currentStep: number; + currentStepData: WizardStep | null; + totalSteps: number; + onNext: () => void; + onPrevious: () => void; + onSkip: () => void; + onComplete: () => void; + onQuickStart: () => void; + hasSampleData: boolean; + onClearSampleData: () => void; + isQuickStartLoading?: boolean; +} + +// Icons for each column/step +const STEP_ICONS: Record> = { + backlog: PlayCircle, + in_progress: Sparkles, + waiting_approval: Lightbulb, + verified: CheckCircle2, + custom_columns: Settings2, +}; + +export function BoardOnboardingWizard({ + isVisible, + currentStep, + currentStepData, + totalSteps, + onNext, + onPrevious, + onSkip, + onComplete, + onQuickStart, + hasSampleData, + onClearSampleData, + isQuickStartLoading = false, +}: BoardOnboardingWizardProps) { + // Store rect as simple object to avoid DOMRect type issues + const [highlightRect, setHighlightRect] = useState<{ + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; + } | null>(null); + const [tooltipPosition, setTooltipPosition] = useState<'left' | 'right' | 'bottom'>('bottom'); + const [isAnimating, setIsAnimating] = useState(false); + const [showCompletionCelebration, setShowCompletionCelebration] = useState(false); + + // Refs for focus management + const dialogRef = useRef(null); + const nextButtonRef = useRef(null); + + // Detect if user is on a touch device + const [isTouchDevice, setIsTouchDevice] = useState(false); + + useEffect(() => { + setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0); + }, []); + + // Lock scroll when wizard is visible + useEffect(() => { + if (!isVisible) return; + + // Prevent body scroll while wizard is open + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = originalOverflow; + }; + }, [isVisible]); + + // Focus management - move focus to dialog when opened + useEffect(() => { + if (!isVisible) return; + + // Focus the next button when wizard opens for keyboard accessibility + const timer = setTimeout(() => { + nextButtonRef.current?.focus(); + }, STEP_TRANSITION_DURATION); + + return () => clearTimeout(timer); + }, [isVisible]); + + // Animate step transitions + useEffect(() => { + if (!isVisible) return; + + setIsAnimating(true); + const timer = setTimeout(() => { + setIsAnimating(false); + }, STEP_TRANSITION_DURATION); + + return () => clearTimeout(timer); + }, [currentStep, isVisible]); + + // Find and highlight the current column + useEffect(() => { + if (!isVisible || !currentStepData) { + setHighlightRect(null); + return; + } + + // Helper to update highlight rect and tooltip position + const updateHighlight = () => { + const columnEl = document.querySelector( + `[data-testid="kanban-column-${currentStepData.columnId}"]` + ); + + if (columnEl) { + const rect = columnEl.getBoundingClientRect(); + setHighlightRect({ + top: rect.top, + left: rect.left, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }); + + // Determine tooltip position based on column position and available space + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const columnCenter = rect.left + rect.width / 2; + const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2); + + // Check if there's enough space at the bottom + const spaceAtBottom = viewportHeight - rect.bottom - TOOLTIP_OFFSET; + const spaceAtRight = viewportWidth - rect.right - TOOLTIP_OFFSET; + const spaceAtLeft = rect.left - TOOLTIP_OFFSET; + + // For leftmost columns, prefer right position + if ( + columnCenter < viewportWidth * TOOLTIP_POSITION_RIGHT_THRESHOLD && + spaceAtRight >= tooltipWidth + ) { + setTooltipPosition('right'); + } + // For rightmost columns, prefer left position + else if ( + columnCenter > viewportWidth * TOOLTIP_POSITION_LEFT_THRESHOLD && + spaceAtLeft >= tooltipWidth + ) { + setTooltipPosition('left'); + } + // For middle columns, check if bottom position would work + else if (spaceAtBottom >= BOTTOM_THRESHOLD) { + setTooltipPosition('bottom'); + } + // If bottom doesn't have enough space, try left or right based on which has more space + else if (spaceAtRight > spaceAtLeft && spaceAtRight >= tooltipWidth * 0.6) { + setTooltipPosition('right'); + } else if (spaceAtLeft >= tooltipWidth * 0.6) { + setTooltipPosition('left'); + } + // Fallback to bottom with scrollable content + else { + setTooltipPosition('bottom'); + } + } + }; + + // Initial update + updateHighlight(); + + // Debounced resize handler for performance + let resizeTimeout: ReturnType; + const handleResize = () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(updateHighlight, RESIZE_DEBOUNCE_MS); + }; + + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + clearTimeout(resizeTimeout); + }; + }, [isVisible, currentStepData]); + + // Keyboard navigation + useEffect(() => { + if (!isVisible) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onSkip(); + } else if (e.key === 'ArrowRight' || e.key === 'Enter') { + if (currentStep < totalSteps - 1) { + onNext(); + } else { + onComplete(); + } + } else if (e.key === 'ArrowLeft') { + onPrevious(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isVisible, currentStep, totalSteps, onNext, onPrevious, onSkip, onComplete]); + + // Calculate tooltip styles based on position and highlight rect + const getTooltipStyles = useCallback((): React.CSSProperties => { + if (!highlightRect) return {}; + + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2); + + switch (tooltipPosition) { + case 'right': { + const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET); + const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN; + return { + position: 'fixed', + top: topPos, + left: highlightRect.right + TOOLTIP_OFFSET, + width: tooltipWidth, + maxWidth: `calc(100vw - ${highlightRect.right + TOOLTIP_OFFSET * 2}px)`, + maxHeight: Math.max(200, availableHeight), + }; + } + case 'left': { + const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET); + const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN; + return { + position: 'fixed', + top: topPos, + right: viewportWidth - highlightRect.left + TOOLTIP_OFFSET, + width: tooltipWidth, + maxWidth: `calc(${highlightRect.left - TOOLTIP_OFFSET * 2}px)`, + maxHeight: Math.max(200, availableHeight), + }; + } + case 'bottom': + default: { + // Calculate available space at bottom + const idealTop = highlightRect.bottom + TOOLTIP_OFFSET; + const availableHeight = viewportHeight - idealTop - VIEWPORT_SAFE_MARGIN; + + // If not enough space, position higher but ensure tooltip stays below header + const minTop = 100; // Minimum distance from top of viewport + const topPos = + availableHeight < 250 + ? Math.max( + minTop, + viewportHeight - Math.max(300, availableHeight) - VIEWPORT_SAFE_MARGIN + ) + : idealTop; + + // Center tooltip under column but keep within viewport bounds + const idealLeft = highlightRect.left + highlightRect.width / 2 - tooltipWidth / 2; + const leftPos = Math.max( + VIEWPORT_SAFE_MARGIN, + Math.min(idealLeft, viewportWidth - tooltipWidth - VIEWPORT_SAFE_MARGIN) + ); + + return { + position: 'fixed', + top: topPos, + left: leftPos, + width: tooltipWidth, + maxHeight: Math.max(200, viewportHeight - topPos - VIEWPORT_SAFE_MARGIN), + }; + } + } + }, [highlightRect, tooltipPosition]); + + // Handle completion with celebration + const handleComplete = useCallback(() => { + setShowCompletionCelebration(true); + // Show celebration briefly before completing + setTimeout(() => { + setShowCompletionCelebration(false); + onComplete(); + }, 1200); + }, [onComplete]); + + // Handle step indicator click for direct navigation + const handleStepClick = useCallback( + (stepIndex: number) => { + if (stepIndex === currentStep) return; + + // Use onNext/onPrevious to properly track analytics + if (stepIndex > currentStep) { + for (let i = currentStep; i < stepIndex; i++) { + onNext(); + } + } else { + for (let i = currentStep; i > stepIndex; i--) { + onPrevious(); + } + } + }, + [currentStep, onNext, onPrevious] + ); + + if (!isVisible || !currentStepData) return null; + + const StepIcon = STEP_ICONS[currentStepData.id] || Sparkles; + const isLastStep = currentStep === totalSteps - 1; + const isFirstStep = currentStep === 0; + + const content = ( +
+ {/* Completion celebration overlay */} + {showCompletionCelebration && ( +
+
+ +

You're all set!

+
+
+ )} + + {/* Dark overlay with cutout for highlighted column */} + + + {/* Highlight border around the column */} + {highlightRect && ( +
+ )} + + {/* Skip button - top right with accessible touch target */} + + + {/* Tooltip/Card with step content */} +
+ {/* Header */} +
+
+ +
+
+

+ {currentStepData.title} +

+
+ + Step {currentStep + 1} of {totalSteps} + + {/* Step indicators - clickable for navigation */} + +
+
+
+ + {/* Description */} +

+ {currentStepData.description} +

+ + {/* Tip box */} + {currentStepData.tip && ( +
+

+ Tip: + {currentStepData.tip} +

+
+ )} + + {/* Quick Start section - only on first step */} + {isFirstStep && ( +
+

+

+

+ Want to see the board in action? We can add some sample tasks to demonstrate the + workflow. +

+
+ + {hasSampleData && ( + + )} +
+
+ )} + + {/* Navigation buttons */} +
+ + + +
+ + {/* Keyboard hints - hidden on touch devices for cleaner mobile UX */} + {!isTouchDevice && ( + + )} +
+
+ ); + + // Render in a portal to ensure it's above everything + return createPortal(content, document.body); +} diff --git a/apps/ui/src/components/views/board-view/components/index.ts b/apps/ui/src/components/views/board-view/components/index.ts index 514e407d6..62692b9cb 100644 --- a/apps/ui/src/components/views/board-view/components/index.ts +++ b/apps/ui/src/components/views/board-view/components/index.ts @@ -1,3 +1,4 @@ export { KanbanCard } from './kanban-card/kanban-card'; export { KanbanColumn } from './kanban-column'; export { SelectionActionBar } from './selection-action-bar'; +export { BoardOnboardingWizard } from './board-onboarding-wizard'; diff --git a/apps/ui/src/components/views/board-view/constants.ts b/apps/ui/src/components/views/board-view/constants.ts index 9302ea01d..38618e977 100644 --- a/apps/ui/src/components/views/board-view/constants.ts +++ b/apps/ui/src/components/views/board-view/constants.ts @@ -89,3 +89,117 @@ export function getStepIdFromStatus(status: string): string | null { } return status.replace('pipeline_', ''); } + +// ============================================================================ +// SAMPLE DATA FOR ONBOARDING WIZARD +// ============================================================================ + +/** + * Prefix used to identify sample/demo features in the board + * This marker persists through the database and is used for cleanup + */ +export const SAMPLE_FEATURE_PREFIX = '[DEMO]'; + +/** + * Sample feature template for Quick Start onboarding + * These demonstrate a typical workflow progression across columns + */ +export interface SampleFeatureTemplate { + title: string; + description: string; + category: string; + status: Feature['status']; + priority: number; + isSampleData: true; // Marker to identify sample data +} + +/** + * Sample features that demonstrate the workflow across all columns. + * Each feature shows a realistic task at different stages. + */ +export const SAMPLE_FEATURES: SampleFeatureTemplate[] = [ + // Backlog items - awaiting work + { + title: '[DEMO] Add user profile page', + description: + 'Create a user profile page where users can view and edit their account settings, change password, and manage preferences.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.', + category: 'Feature', + status: 'backlog', + priority: 1, + isSampleData: true, + }, + { + title: '[DEMO] Implement dark mode toggle', + description: + 'Add a toggle in the settings to switch between light and dark themes. Should persist the preference across sessions.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.', + category: 'Enhancement', + status: 'backlog', + priority: 2, + isSampleData: true, + }, + + // In Progress - currently being worked on + { + title: '[DEMO] Fix login timeout issue', + description: + 'Users are being logged out after 5 minutes of inactivity. Investigate and increase the session timeout to 30 minutes.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.', + category: 'Bug Fix', + status: 'in_progress', + priority: 1, + isSampleData: true, + }, + + // Waiting Approval - completed and awaiting review + { + title: '[DEMO] Update API documentation', + description: + 'Update the API documentation to reflect recent endpoint changes and add examples for new authentication flow.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.', + category: 'Documentation', + status: 'waiting_approval', + priority: 2, + isSampleData: true, + }, + + // Verified - approved and ready + { + title: '[DEMO] Add loading spinners', + description: + 'Added loading spinner components to all async operations to improve user feedback during data fetching.\n\n---\n**This is sample data** - Click the trash icon in the wizard to remove all demo items.', + category: 'Enhancement', + status: 'verified', + priority: 3, + isSampleData: true, + }, +]; + +/** + * Check if a feature is sample data + * Uses the SAMPLE_FEATURE_PREFIX in the title as the marker for sample data + */ +export function isSampleFeature(feature: Partial): boolean { + // Check title prefix - this is the reliable marker that persists through the database + return feature.title?.startsWith(SAMPLE_FEATURE_PREFIX) ?? false; +} + +/** + * Generate sample feature data with unique IDs + * @returns Array of sample features ready to be created + */ +export function generateSampleFeatures(): Array> { + return SAMPLE_FEATURES.map((template) => ({ + title: template.title, + description: template.description, + category: template.category, + status: template.status, + priority: template.priority, + images: [], + imagePaths: [], + skipTests: true, + model: 'sonnet' as const, + thinkingLevel: 'none' as const, + planningMode: 'skip' as const, + requirePlanApproval: false, + // Mark as sample data in a way that persists + // We use the title prefix [DEMO] as the marker + })); +} diff --git a/apps/ui/src/components/views/board-view/hooks/index.ts b/apps/ui/src/components/views/board-view/hooks/index.ts index 272937f45..230311ced 100644 --- a/apps/ui/src/components/views/board-view/hooks/index.ts +++ b/apps/ui/src/components/views/board-view/hooks/index.ts @@ -8,3 +8,4 @@ export { useBoardBackground } from './use-board-background'; export { useBoardPersistence } from './use-board-persistence'; export { useFollowUpState } from './use-follow-up-state'; export { useSelectionMode } from './use-selection-mode'; +export { useBoardOnboarding } from './use-board-onboarding'; diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-onboarding.ts b/apps/ui/src/components/views/board-view/hooks/use-board-onboarding.ts new file mode 100644 index 000000000..e25008af6 --- /dev/null +++ b/apps/ui/src/components/views/board-view/hooks/use-board-onboarding.ts @@ -0,0 +1,409 @@ +/** + * Board Onboarding Hook + * + * Manages the state and logic for the interactive onboarding wizard + * that guides new users through the Kanban board workflow. + * + * Features: + * - Tracks wizard completion status per project + * - Persists state to localStorage (per user, per board) + * - Handles step navigation + * - Provides analytics tracking + */ + +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { getItem, setItem } from '@/lib/storage'; + +const logger = createLogger('BoardOnboarding'); + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +/** Storage key prefix for onboarding state */ +const ONBOARDING_STORAGE_KEY = 'automaker:board-onboarding'; + +/** Delay before auto-showing wizard to let the board render first (ms) */ +const WIZARD_AUTO_SHOW_DELAY_MS = 500; + +/** Maximum length for project path hash in storage key */ +const PROJECT_PATH_HASH_MAX_LENGTH = 50; + +// Analytics event names +export const ONBOARDING_ANALYTICS = { + STARTED: 'onboarding_started', + COMPLETED: 'onboarding_completed', + SKIPPED: 'onboarding_skipped', + QUICK_START_USED: 'onboarding_quick_start_used', + SAMPLE_DATA_CLEARED: 'onboarding_sample_data_cleared', + STEP_VIEWED: 'onboarding_step_viewed', + RETRIGGERED: 'onboarding_retriggered', +} as const; + +// Wizard step definitions +export interface WizardStep { + id: string; + columnId: string; + title: string; + description: string; + tip?: string; +} + +export const WIZARD_STEPS: WizardStep[] = [ + { + id: 'backlog', + columnId: 'backlog', + title: 'Backlog', + description: + 'This is where all your planned tasks live. Add new features, bug fixes, or improvements here. When you\'re ready to work on something, drag it to "In Progress" or click the play button.', + tip: 'Press N or click the + button to quickly add a new feature.', + }, + { + id: 'in_progress', + columnId: 'in_progress', + title: 'In Progress', + description: + 'Tasks being actively worked on appear here. AI agents automatically pick up items from the backlog and move them here when processing begins.', + tip: 'You can run multiple tasks simultaneously using Auto Mode.', + }, + { + id: 'waiting_approval', + columnId: 'waiting_approval', + title: 'Waiting Approval', + description: + 'Completed work lands here for your review. Check the changes, run tests, and approve or send back for revisions.', + tip: 'Click "View Output" to see what the AI agent did.', + }, + { + id: 'verified', + columnId: 'verified', + title: 'Verified', + description: + "Approved and verified tasks are ready for deployment! Archive them when you're done or move them back if changes are needed.", + tip: 'Click "Complete All" to archive all verified items at once.', + }, + { + id: 'custom_columns', + columnId: 'in_progress', // Highlight "In Progress" column to show the settings icon + title: 'Custom Pipelines', + description: + 'You can create custom columns (called pipelines) to build your own workflow! Click the settings icon in any column header to add, rename, or configure pipeline steps.', + tip: 'Use pipelines to add code review, QA testing, or any custom stage to your workflow.', + }, +]; + +// Persisted onboarding state structure +interface OnboardingState { + completed: boolean; + completedAt?: string; + skipped: boolean; + skippedAt?: string; + hasEverSeenWizard: boolean; + hasSampleData: boolean; + quickStartUsed: boolean; +} + +// Default state for new projects +const DEFAULT_ONBOARDING_STATE: OnboardingState = { + completed: false, + skipped: false, + hasEverSeenWizard: false, + hasSampleData: false, + quickStartUsed: false, +}; + +/** + * Get storage key for a specific project + * Creates a sanitized key from the project path for localStorage + */ +function getStorageKey(projectPath: string): string { + // Create a simple hash of the project path to use as key + const hash = projectPath.replace(/[^a-zA-Z0-9]/g, '_').slice(0, PROJECT_PATH_HASH_MAX_LENGTH); + return `${ONBOARDING_STORAGE_KEY}:${hash}`; +} + +// Load onboarding state from localStorage +function loadOnboardingState(projectPath: string): OnboardingState { + try { + const key = getStorageKey(projectPath); + const stored = getItem(key); + if (stored) { + return JSON.parse(stored) as OnboardingState; + } + } catch (error) { + logger.error('Failed to load onboarding state:', error); + } + return { ...DEFAULT_ONBOARDING_STATE }; +} + +// Save onboarding state to localStorage +function saveOnboardingState(projectPath: string, state: OnboardingState): void { + try { + const key = getStorageKey(projectPath); + setItem(key, JSON.stringify(state)); + } catch (error) { + logger.error('Failed to save onboarding state:', error); + } +} + +// Track analytics event (placeholder - integrate with actual analytics service) +function trackAnalytics(event: string, data?: Record): void { + logger.debug(`[Analytics] ${event}`, data); + // TODO: Integrate with actual analytics service (e.g., PostHog, Amplitude) + // Example: posthog.capture(event, data); +} + +export interface UseBoardOnboardingOptions { + projectPath: string | null; + isEmpty: boolean; // Whether the board has no features + totalFeatureCount: number; // Total number of features in the board + /** Whether the spec generation dialog is currently open (prevents wizard from showing) */ + isSpecDialogOpen?: boolean; +} + +export interface UseBoardOnboardingResult { + // Wizard visibility + isWizardVisible: boolean; + shouldShowWizard: boolean; + + // Current step + currentStep: number; + currentStepData: WizardStep | null; + totalSteps: number; + + // Navigation + goToNextStep: () => void; + goToPreviousStep: () => void; + goToStep: (step: number) => void; + + // Actions + startWizard: () => void; + completeWizard: () => void; + skipWizard: () => void; + dismissWizard: () => void; + + // Quick Start / Sample Data + hasSampleData: boolean; + setHasSampleData: (has: boolean) => void; + markQuickStartUsed: () => void; + + // Re-trigger + canRetrigger: boolean; + retriggerWizard: () => void; + + // State + isCompleted: boolean; + isSkipped: boolean; +} + +export function useBoardOnboarding({ + projectPath, + isEmpty, + totalFeatureCount, + isSpecDialogOpen = false, +}: UseBoardOnboardingOptions): UseBoardOnboardingResult { + // Local state + const [currentStep, setCurrentStep] = useState(0); + const [isWizardActive, setIsWizardActive] = useState(false); + const [onboardingState, setOnboardingState] = useState(DEFAULT_ONBOARDING_STATE); + + // Load persisted state when project changes + useEffect(() => { + if (!projectPath) { + setOnboardingState(DEFAULT_ONBOARDING_STATE); + return; + } + + const state = loadOnboardingState(projectPath); + setOnboardingState(state); + + // Auto-show wizard for empty boards that haven't seen it + // Don't re-trigger if board became empty after having features (edge case) + // Don't show if spec dialog is open (for new projects) + if ( + isEmpty && + !state.hasEverSeenWizard && + !state.completed && + !state.skipped && + !isSpecDialogOpen + ) { + // Small delay to let the board render first + const timer = setTimeout(() => { + setIsWizardActive(true); + trackAnalytics(ONBOARDING_ANALYTICS.STARTED, { projectPath }); + }, WIZARD_AUTO_SHOW_DELAY_MS); + return () => clearTimeout(timer); + } + }, [projectPath, isEmpty, isSpecDialogOpen]); + + // Update persisted state helper + const updateState = useCallback( + (updates: Partial) => { + if (!projectPath) return; + + setOnboardingState((prev) => { + const newState = { ...prev, ...updates }; + saveOnboardingState(projectPath, newState); + return newState; + }); + }, + [projectPath] + ); + + // Determine if wizard should be visible + // Don't show if: + // - No project selected + // - Already completed or skipped + // - Board has features and user has seen wizard before (became empty after deletion) + const shouldShowWizard = useMemo(() => { + if (!projectPath) return false; + if (onboardingState.completed || onboardingState.skipped) return false; + if (!isEmpty && onboardingState.hasEverSeenWizard) return false; + return isEmpty && !onboardingState.hasEverSeenWizard; + }, [projectPath, isEmpty, onboardingState]); + + // Current step data + const currentStepData = WIZARD_STEPS[currentStep] || null; + const totalSteps = WIZARD_STEPS.length; + + // Navigation handlers + const goToNextStep = useCallback(() => { + if (currentStep < totalSteps - 1) { + const nextStep = currentStep + 1; + setCurrentStep(nextStep); + trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, { + step: nextStep, + stepId: WIZARD_STEPS[nextStep]?.id, + projectPath, + }); + } + }, [currentStep, totalSteps, projectPath]); + + const goToPreviousStep = useCallback(() => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }, [currentStep]); + + const goToStep = useCallback( + (step: number) => { + if (step >= 0 && step < totalSteps) { + setCurrentStep(step); + trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, { + step, + stepId: WIZARD_STEPS[step]?.id, + projectPath, + }); + } + }, + [totalSteps, projectPath] + ); + + // Wizard lifecycle handlers + const startWizard = useCallback(() => { + setCurrentStep(0); + setIsWizardActive(true); + updateState({ hasEverSeenWizard: true }); + trackAnalytics(ONBOARDING_ANALYTICS.STARTED, { projectPath }); + }, [projectPath, updateState]); + + const completeWizard = useCallback(() => { + setIsWizardActive(false); + setCurrentStep(0); + updateState({ + completed: true, + completedAt: new Date().toISOString(), + hasEverSeenWizard: true, + }); + trackAnalytics(ONBOARDING_ANALYTICS.COMPLETED, { + projectPath, + quickStartUsed: onboardingState.quickStartUsed, + totalFeatureCount, + }); + }, [projectPath, updateState, onboardingState.quickStartUsed, totalFeatureCount]); + + const skipWizard = useCallback(() => { + setIsWizardActive(false); + setCurrentStep(0); + updateState({ + skipped: true, + skippedAt: new Date().toISOString(), + hasEverSeenWizard: true, + }); + trackAnalytics(ONBOARDING_ANALYTICS.SKIPPED, { + projectPath, + skippedAtStep: currentStep, + }); + }, [projectPath, currentStep, updateState]); + + const dismissWizard = useCallback(() => { + // Same as skip but doesn't mark as "skipped" - just closes the wizard + setIsWizardActive(false); + updateState({ hasEverSeenWizard: true }); + }, [updateState]); + + // Quick Start / Sample Data + const setHasSampleData = useCallback( + (has: boolean) => { + updateState({ hasSampleData: has }); + if (!has) { + trackAnalytics(ONBOARDING_ANALYTICS.SAMPLE_DATA_CLEARED, { projectPath }); + } + }, + [projectPath, updateState] + ); + + const markQuickStartUsed = useCallback(() => { + updateState({ quickStartUsed: true, hasSampleData: true }); + trackAnalytics(ONBOARDING_ANALYTICS.QUICK_START_USED, { projectPath }); + }, [projectPath, updateState]); + + // Re-trigger wizard - memoized for stable reference + const canRetrigger = useMemo( + () => onboardingState.completed || onboardingState.skipped, + [onboardingState.completed, onboardingState.skipped] + ); + + const retriggerWizard = useCallback(() => { + setCurrentStep(0); + setIsWizardActive(true); + // Don't reset completion status, just show wizard again + trackAnalytics(ONBOARDING_ANALYTICS.RETRIGGERED, { projectPath }); + }, [projectPath]); + + return { + // Visibility + isWizardVisible: isWizardActive, + shouldShowWizard, + + // Steps + currentStep, + currentStepData, + totalSteps, + + // Navigation + goToNextStep, + goToPreviousStep, + goToStep, + + // Actions + startWizard, + completeWizard, + skipWizard, + dismissWizard, + + // Sample Data + hasSampleData: onboardingState.hasSampleData, + setHasSampleData, + markQuickStartUsed, + + // Re-trigger + canRetrigger, + retriggerWizard, + + // State + isCompleted: onboardingState.completed, + isSkipped: onboardingState.skipped, + }; +} From d4ce1f331b706b37ab11dbdc6bde319534c369af Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 11 Jan 2026 16:05:04 +0100 Subject: [PATCH 2/2] feat: add onboarding wizard components and logic - Introduced a new onboarding wizard for guiding users through the board features. - Added shared onboarding components including OnboardingWizard, useOnboardingWizard, and related types and constants. - Implemented onboarding logic in the BoardView, integrating sample feature generation for a quick start. - Updated UI components to support onboarding interactions and improve user experience. - Enhanced onboarding state management with localStorage persistence for user progress tracking. --- apps/ui/src/components/shared/index.ts | 14 + .../components/shared/onboarding/constants.ts | 55 ++ .../src/components/shared/onboarding/index.ts | 21 + .../shared/onboarding/onboarding-wizard.tsx | 545 +++++++++++++ .../src/components/shared/onboarding/types.ts | 109 +++ .../onboarding/use-onboarding-wizard.ts | 216 ++++++ apps/ui/src/components/views/board-view.tsx | 14 +- .../views/board-view/board-controls.tsx | 15 +- .../views/board-view/board-header.tsx | 9 +- .../components/board-onboarding-wizard.tsx | 727 +++--------------- .../board-view/components/kanban-column.tsx | 1 + .../board-view/hooks/use-board-onboarding.ts | 404 ++++------ .../views/board-view/kanban-board.tsx | 1 + 13 files changed, 1202 insertions(+), 929 deletions(-) create mode 100644 apps/ui/src/components/shared/onboarding/constants.ts create mode 100644 apps/ui/src/components/shared/onboarding/index.ts create mode 100644 apps/ui/src/components/shared/onboarding/onboarding-wizard.tsx create mode 100644 apps/ui/src/components/shared/onboarding/types.ts create mode 100644 apps/ui/src/components/shared/onboarding/use-onboarding-wizard.ts diff --git a/apps/ui/src/components/shared/index.ts b/apps/ui/src/components/shared/index.ts index 2497d4092..5060ebd14 100644 --- a/apps/ui/src/components/shared/index.ts +++ b/apps/ui/src/components/shared/index.ts @@ -5,3 +5,17 @@ export { type UseModelOverrideOptions, type UseModelOverrideResult, } from './use-model-override'; + +// Onboarding Wizard Components +export { + OnboardingWizard, + useOnboardingWizard, + ONBOARDING_STORAGE_PREFIX, + ONBOARDING_TARGET_ATTRIBUTE, + ONBOARDING_ANALYTICS, + type OnboardingStep, + type OnboardingState, + type OnboardingWizardProps, + type UseOnboardingWizardOptions, + type UseOnboardingWizardResult, +} from './onboarding'; diff --git a/apps/ui/src/components/shared/onboarding/constants.ts b/apps/ui/src/components/shared/onboarding/constants.ts new file mode 100644 index 000000000..d001634ee --- /dev/null +++ b/apps/ui/src/components/shared/onboarding/constants.ts @@ -0,0 +1,55 @@ +/** + * Shared Onboarding Wizard Constants + * + * Layout, positioning, and timing constants for the onboarding wizard. + */ + +/** Storage key prefix for onboarding state */ +export const ONBOARDING_STORAGE_PREFIX = 'automaker:onboarding'; + +/** Padding around spotlight highlight elements (px) */ +export const SPOTLIGHT_PADDING = 8; + +/** Padding between target element and tooltip (px) */ +export const TOOLTIP_OFFSET = 16; + +/** Vertical offset from top of target to tooltip (px) */ +export const TOOLTIP_TOP_OFFSET = 40; + +/** Maximum tooltip width (px) */ +export const TOOLTIP_MAX_WIDTH = 400; + +/** Minimum safe margin from viewport edges (px) */ +export const VIEWPORT_SAFE_MARGIN = 16; + +/** Threshold for placing tooltip to the right of target (30% of viewport) */ +export const TOOLTIP_POSITION_RIGHT_THRESHOLD = 0.3; + +/** Threshold for placing tooltip to the left of target (70% of viewport) */ +export const TOOLTIP_POSITION_LEFT_THRESHOLD = 0.7; + +/** Threshold from bottom of viewport to trigger alternate positioning (px) */ +export const BOTTOM_THRESHOLD = 450; + +/** Debounce delay for resize handler (ms) */ +export const RESIZE_DEBOUNCE_MS = 100; + +/** Animation duration for step transitions (ms) */ +export const STEP_TRANSITION_DURATION = 200; + +/** ID for the wizard description element (for aria-describedby) */ +export const WIZARD_DESCRIPTION_ID = 'onboarding-wizard-description'; + +/** ID for the wizard title element (for aria-labelledby) */ +export const WIZARD_TITLE_ID = 'onboarding-wizard-title'; + +/** Data attribute name for targeting elements */ +export const ONBOARDING_TARGET_ATTRIBUTE = 'data-onboarding-target'; + +/** Analytics event names for onboarding tracking */ +export const ONBOARDING_ANALYTICS = { + STARTED: 'onboarding_started', + COMPLETED: 'onboarding_completed', + SKIPPED: 'onboarding_skipped', + STEP_VIEWED: 'onboarding_step_viewed', +} as const; diff --git a/apps/ui/src/components/shared/onboarding/index.ts b/apps/ui/src/components/shared/onboarding/index.ts new file mode 100644 index 000000000..26b117c32 --- /dev/null +++ b/apps/ui/src/components/shared/onboarding/index.ts @@ -0,0 +1,21 @@ +/** + * Shared Onboarding Components + * + * Generic onboarding wizard infrastructure for building + * interactive tutorials across different views. + */ + +export { OnboardingWizard } from './onboarding-wizard'; +export { useOnboardingWizard } from './use-onboarding-wizard'; +export type { + OnboardingStep, + OnboardingState, + OnboardingWizardProps, + UseOnboardingWizardOptions, + UseOnboardingWizardResult, +} from './types'; +export { + ONBOARDING_STORAGE_PREFIX, + ONBOARDING_TARGET_ATTRIBUTE, + ONBOARDING_ANALYTICS, +} from './constants'; diff --git a/apps/ui/src/components/shared/onboarding/onboarding-wizard.tsx b/apps/ui/src/components/shared/onboarding/onboarding-wizard.tsx new file mode 100644 index 000000000..70bbcb9bc --- /dev/null +++ b/apps/ui/src/components/shared/onboarding/onboarding-wizard.tsx @@ -0,0 +1,545 @@ +/** + * Generic Onboarding Wizard Component + * + * A multi-step wizard overlay that guides users through features + * with visual highlighting (spotlight effect) on target elements. + * + * Features: + * - Spotlight overlay targeting elements via data-onboarding-target + * - Responsive tooltip positioning (left/right/bottom) + * - Step navigation (keyboard & mouse) + * - Configurable children slot for view-specific content + * - Completion celebration animation + * - Full accessibility (ARIA, focus management) + */ + +import { useEffect, useRef, useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { X, ChevronLeft, ChevronRight, CheckCircle2, PartyPopper, Sparkles } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { + SPOTLIGHT_PADDING, + TOOLTIP_OFFSET, + TOOLTIP_TOP_OFFSET, + TOOLTIP_MAX_WIDTH, + VIEWPORT_SAFE_MARGIN, + TOOLTIP_POSITION_RIGHT_THRESHOLD, + TOOLTIP_POSITION_LEFT_THRESHOLD, + BOTTOM_THRESHOLD, + RESIZE_DEBOUNCE_MS, + STEP_TRANSITION_DURATION, + WIZARD_DESCRIPTION_ID, + WIZARD_TITLE_ID, + ONBOARDING_TARGET_ATTRIBUTE, +} from './constants'; +import type { OnboardingWizardProps, OnboardingStep } from './types'; + +interface HighlightRect { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; +} + +export function OnboardingWizard({ + isVisible, + currentStep, + currentStepData, + totalSteps, + onNext, + onPrevious, + onSkip, + onComplete, + steps, + children, +}: OnboardingWizardProps) { + const [highlightRect, setHighlightRect] = useState(null); + const [tooltipPosition, setTooltipPosition] = useState<'left' | 'right' | 'bottom'>('bottom'); + const [isAnimating, setIsAnimating] = useState(false); + const [showCompletionCelebration, setShowCompletionCelebration] = useState(false); + + // Refs for focus management + const dialogRef = useRef(null); + const nextButtonRef = useRef(null); + + // Detect if user is on a touch device + const [isTouchDevice, setIsTouchDevice] = useState(false); + + useEffect(() => { + setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0); + }, []); + + // Lock scroll when wizard is visible + useEffect(() => { + if (!isVisible) return; + + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = originalOverflow; + }; + }, [isVisible]); + + // Focus management - move focus to dialog when opened + useEffect(() => { + if (!isVisible) return; + + const timer = setTimeout(() => { + nextButtonRef.current?.focus(); + }, STEP_TRANSITION_DURATION); + + return () => clearTimeout(timer); + }, [isVisible]); + + // Animate step transitions + useEffect(() => { + if (!isVisible) return; + + setIsAnimating(true); + const timer = setTimeout(() => { + setIsAnimating(false); + }, STEP_TRANSITION_DURATION); + + return () => clearTimeout(timer); + }, [currentStep, isVisible]); + + // Find and highlight the target element + useEffect(() => { + if (!isVisible || !currentStepData) { + setHighlightRect(null); + return; + } + + const updateHighlight = () => { + // Find target element by data-onboarding-target attribute + const targetEl = document.querySelector( + `[${ONBOARDING_TARGET_ATTRIBUTE}="${currentStepData.targetId}"]` + ); + + if (targetEl) { + const rect = targetEl.getBoundingClientRect(); + setHighlightRect({ + top: rect.top, + left: rect.left, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }); + + // Determine tooltip position based on target position and available space + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const targetCenter = rect.left + rect.width / 2; + const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2); + + const spaceAtBottom = viewportHeight - rect.bottom - TOOLTIP_OFFSET; + const spaceAtRight = viewportWidth - rect.right - TOOLTIP_OFFSET; + const spaceAtLeft = rect.left - TOOLTIP_OFFSET; + + // For leftmost targets, prefer right position + if ( + targetCenter < viewportWidth * TOOLTIP_POSITION_RIGHT_THRESHOLD && + spaceAtRight >= tooltipWidth + ) { + setTooltipPosition('right'); + } + // For rightmost targets, prefer left position + else if ( + targetCenter > viewportWidth * TOOLTIP_POSITION_LEFT_THRESHOLD && + spaceAtLeft >= tooltipWidth + ) { + setTooltipPosition('left'); + } + // For middle targets, check if bottom position would work + else if (spaceAtBottom >= BOTTOM_THRESHOLD) { + setTooltipPosition('bottom'); + } + // Fallback logic + else if (spaceAtRight > spaceAtLeft && spaceAtRight >= tooltipWidth * 0.6) { + setTooltipPosition('right'); + } else if (spaceAtLeft >= tooltipWidth * 0.6) { + setTooltipPosition('left'); + } else { + setTooltipPosition('bottom'); + } + } + }; + + updateHighlight(); + + // Debounced resize handler + let resizeTimeout: ReturnType; + const handleResize = () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(updateHighlight, RESIZE_DEBOUNCE_MS); + }; + + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + clearTimeout(resizeTimeout); + }; + }, [isVisible, currentStepData]); + + // Keyboard navigation + useEffect(() => { + if (!isVisible) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onSkip(); + } else if (e.key === 'ArrowRight' || e.key === 'Enter') { + if (currentStep < totalSteps - 1) { + onNext(); + } else { + handleComplete(); + } + } else if (e.key === 'ArrowLeft') { + onPrevious(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isVisible, currentStep, totalSteps, onNext, onPrevious, onSkip]); + + // Calculate tooltip styles based on position and highlight rect + const getTooltipStyles = useCallback((): React.CSSProperties => { + if (!highlightRect) return {}; + + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2); + + switch (tooltipPosition) { + case 'right': { + const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET); + const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN; + return { + position: 'fixed', + top: topPos, + left: highlightRect.right + TOOLTIP_OFFSET, + width: tooltipWidth, + maxWidth: `calc(100vw - ${highlightRect.right + TOOLTIP_OFFSET * 2}px)`, + maxHeight: Math.max(200, availableHeight), + }; + } + case 'left': { + const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET); + const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN; + return { + position: 'fixed', + top: topPos, + right: viewportWidth - highlightRect.left + TOOLTIP_OFFSET, + width: tooltipWidth, + maxWidth: `calc(${highlightRect.left - TOOLTIP_OFFSET * 2}px)`, + maxHeight: Math.max(200, availableHeight), + }; + } + case 'bottom': + default: { + const idealTop = highlightRect.bottom + TOOLTIP_OFFSET; + const availableHeight = viewportHeight - idealTop - VIEWPORT_SAFE_MARGIN; + + const minTop = 100; + const topPos = + availableHeight < 250 + ? Math.max( + minTop, + viewportHeight - Math.max(300, availableHeight) - VIEWPORT_SAFE_MARGIN + ) + : idealTop; + + const idealLeft = highlightRect.left + highlightRect.width / 2 - tooltipWidth / 2; + const leftPos = Math.max( + VIEWPORT_SAFE_MARGIN, + Math.min(idealLeft, viewportWidth - tooltipWidth - VIEWPORT_SAFE_MARGIN) + ); + + return { + position: 'fixed', + top: topPos, + left: leftPos, + width: tooltipWidth, + maxHeight: Math.max(200, viewportHeight - topPos - VIEWPORT_SAFE_MARGIN), + }; + } + } + }, [highlightRect, tooltipPosition]); + + // Handle completion with celebration + const handleComplete = useCallback(() => { + setShowCompletionCelebration(true); + setTimeout(() => { + setShowCompletionCelebration(false); + onComplete(); + }, 1200); + }, [onComplete]); + + // Handle step indicator click for direct navigation + const handleStepClick = useCallback( + (stepIndex: number) => { + if (stepIndex === currentStep) return; + + if (stepIndex > currentStep) { + for (let i = currentStep; i < stepIndex; i++) { + onNext(); + } + } else { + for (let i = currentStep; i > stepIndex; i--) { + onPrevious(); + } + } + }, + [currentStep, onNext, onPrevious] + ); + + if (!isVisible || !currentStepData) return null; + + const StepIcon = currentStepData.icon || Sparkles; + const isLastStep = currentStep === totalSteps - 1; + const isFirstStep = currentStep === 0; + + const content = ( +
+ {/* Completion celebration overlay */} + {showCompletionCelebration && ( +
+
+ +

You're all set!

+
+
+ )} + + {/* Dark overlay with cutout for highlighted element */} + + + {/* Highlight border around the target element */} + {highlightRect && ( +
+ )} + + {/* Skip button - top right */} + + + {/* Tooltip card with step content */} +
+ {/* Header */} +
+
+ +
+
+

+ {currentStepData.title} +

+
+ + Step {currentStep + 1} of {totalSteps} + + {/* Step indicators - clickable for navigation */} + +
+
+
+ + {/* Description */} +

+ {currentStepData.description} +

+ + {/* Tip box */} + {currentStepData.tip && ( +
+

+ Tip: + {currentStepData.tip} +

+
+ )} + + {/* Custom content slot (e.g., Quick Start section) */} + {children} + + {/* Navigation buttons */} +
+ + + +
+ + {/* Keyboard hints - hidden on touch devices */} + {!isTouchDevice && ( + + )} +
+
+ ); + + // Render in a portal to ensure it's above everything + return createPortal(content, document.body); +} diff --git a/apps/ui/src/components/shared/onboarding/types.ts b/apps/ui/src/components/shared/onboarding/types.ts new file mode 100644 index 000000000..ce934778f --- /dev/null +++ b/apps/ui/src/components/shared/onboarding/types.ts @@ -0,0 +1,109 @@ +/** + * Shared Onboarding Wizard Types + * + * Generic types for building onboarding wizards across different views. + */ + +import type { ComponentType } from 'react'; + +/** + * Represents a single step in the onboarding wizard + */ +export interface OnboardingStep { + /** Unique identifier for this step */ + id: string; + /** Target element ID - matches data-onboarding-target attribute */ + targetId: string; + /** Step title displayed in the wizard */ + title: string; + /** Main description explaining this step */ + description: string; + /** Optional tip shown in a highlighted box */ + tip?: string; + /** Optional icon component for visual identification */ + icon?: ComponentType<{ className?: string }>; +} + +/** + * Persisted onboarding state structure + */ +export interface OnboardingState { + /** Whether the wizard has been completed */ + completed: boolean; + /** ISO timestamp when completed */ + completedAt?: string; + /** Whether the wizard has been skipped */ + skipped: boolean; + /** ISO timestamp when skipped */ + skippedAt?: string; +} + +/** + * Options for the useOnboardingWizard hook + */ +export interface UseOnboardingWizardOptions { + /** Unique storage key for localStorage persistence */ + storageKey: string; + /** Array of wizard steps to display */ + steps: OnboardingStep[]; + /** Optional callback when wizard is completed */ + onComplete?: () => void; + /** Optional callback when wizard is skipped */ + onSkip?: () => void; +} + +/** + * Return type for the useOnboardingWizard hook + */ +export interface UseOnboardingWizardResult { + /** Whether the wizard is currently visible */ + isVisible: boolean; + /** Current step index (0-based) */ + currentStep: number; + /** Current step data or null if not available */ + currentStepData: OnboardingStep | null; + /** Total number of steps */ + totalSteps: number; + /** Navigate to the next step */ + goToNextStep: () => void; + /** Navigate to the previous step */ + goToPreviousStep: () => void; + /** Navigate to a specific step by index */ + goToStep: (step: number) => void; + /** Start/show the wizard from the beginning */ + startWizard: () => void; + /** Complete the wizard and hide it */ + completeWizard: () => void; + /** Skip the wizard and hide it */ + skipWizard: () => void; + /** Whether the wizard has been completed */ + isCompleted: boolean; + /** Whether the wizard has been skipped */ + isSkipped: boolean; +} + +/** + * Props for the OnboardingWizard component + */ +export interface OnboardingWizardProps { + /** Whether the wizard is visible */ + isVisible: boolean; + /** Current step index */ + currentStep: number; + /** Current step data */ + currentStepData: OnboardingStep | null; + /** Total number of steps */ + totalSteps: number; + /** Handler for next step navigation */ + onNext: () => void; + /** Handler for previous step navigation */ + onPrevious: () => void; + /** Handler for skipping the wizard */ + onSkip: () => void; + /** Handler for completing the wizard */ + onComplete: () => void; + /** Array of all steps (for step indicator navigation) */ + steps: OnboardingStep[]; + /** Optional content to render before navigation buttons (e.g., Quick Start) */ + children?: React.ReactNode; +} diff --git a/apps/ui/src/components/shared/onboarding/use-onboarding-wizard.ts b/apps/ui/src/components/shared/onboarding/use-onboarding-wizard.ts new file mode 100644 index 000000000..a545f0912 --- /dev/null +++ b/apps/ui/src/components/shared/onboarding/use-onboarding-wizard.ts @@ -0,0 +1,216 @@ +/** + * Generic Onboarding Wizard Hook + * + * Manages the state and logic for interactive onboarding wizards. + * Can be used to create onboarding experiences for any view. + * + * Features: + * - Persists completion status to localStorage + * - Step navigation (next, previous, jump to step) + * - Analytics tracking hooks + * - No auto-show logic - wizard only shows via startWizard() + */ + +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { createLogger } from '@automaker/utils/logger'; +import { getItem, setItem } from '@/lib/storage'; +import { ONBOARDING_STORAGE_PREFIX, ONBOARDING_ANALYTICS } from './constants'; +import type { + OnboardingState, + OnboardingStep, + UseOnboardingWizardOptions, + UseOnboardingWizardResult, +} from './types'; + +const logger = createLogger('OnboardingWizard'); + +/** Default state for new wizards */ +const DEFAULT_ONBOARDING_STATE: OnboardingState = { + completed: false, + skipped: false, +}; + +/** + * Load onboarding state from localStorage + */ +function loadOnboardingState(storageKey: string): OnboardingState { + try { + const fullKey = `${ONBOARDING_STORAGE_PREFIX}:${storageKey}`; + const stored = getItem(fullKey); + if (stored) { + return JSON.parse(stored) as OnboardingState; + } + } catch (error) { + logger.error('Failed to load onboarding state:', error); + } + return { ...DEFAULT_ONBOARDING_STATE }; +} + +/** + * Save onboarding state to localStorage + */ +function saveOnboardingState(storageKey: string, state: OnboardingState): void { + try { + const fullKey = `${ONBOARDING_STORAGE_PREFIX}:${storageKey}`; + setItem(fullKey, JSON.stringify(state)); + } catch (error) { + logger.error('Failed to save onboarding state:', error); + } +} + +/** + * Track analytics event (placeholder - integrate with actual analytics service) + */ +function trackAnalytics(event: string, data?: Record): void { + logger.debug(`[Analytics] ${event}`, data); +} + +/** + * Generic hook for managing onboarding wizard state. + * + * @example + * ```tsx + * const wizard = useOnboardingWizard({ + * storageKey: 'my-view-onboarding', + * steps: MY_WIZARD_STEPS, + * onComplete: () => console.log('Done!'), + * }); + * + * // Start the wizard when user clicks help button + * + * + * // Render the wizard + * + * ``` + */ +export function useOnboardingWizard({ + storageKey, + steps, + onComplete, + onSkip, +}: UseOnboardingWizardOptions): UseOnboardingWizardResult { + const [currentStep, setCurrentStep] = useState(0); + const [isWizardVisible, setIsWizardVisible] = useState(false); + const [onboardingState, setOnboardingState] = useState(DEFAULT_ONBOARDING_STATE); + + // Load persisted state on mount + useEffect(() => { + const state = loadOnboardingState(storageKey); + setOnboardingState(state); + }, [storageKey]); + + // Update persisted state helper + const updateState = useCallback( + (updates: Partial) => { + setOnboardingState((prev) => { + const newState = { ...prev, ...updates }; + saveOnboardingState(storageKey, newState); + return newState; + }); + }, + [storageKey] + ); + + // Current step data + const currentStepData = useMemo(() => steps[currentStep] || null, [steps, currentStep]); + const totalSteps = steps.length; + + // Navigation handlers + const goToNextStep = useCallback(() => { + if (currentStep < totalSteps - 1) { + const nextStep = currentStep + 1; + setCurrentStep(nextStep); + trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, { + storageKey, + step: nextStep, + stepId: steps[nextStep]?.id, + }); + } + }, [currentStep, totalSteps, storageKey, steps]); + + const goToPreviousStep = useCallback(() => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }, [currentStep]); + + const goToStep = useCallback( + (step: number) => { + if (step >= 0 && step < totalSteps) { + setCurrentStep(step); + trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, { + storageKey, + step, + stepId: steps[step]?.id, + }); + } + }, + [totalSteps, storageKey, steps] + ); + + // Wizard lifecycle handlers + const startWizard = useCallback(() => { + setCurrentStep(0); + setIsWizardVisible(true); + trackAnalytics(ONBOARDING_ANALYTICS.STARTED, { storageKey }); + }, [storageKey]); + + const completeWizard = useCallback(() => { + setIsWizardVisible(false); + setCurrentStep(0); + updateState({ + completed: true, + completedAt: new Date().toISOString(), + }); + trackAnalytics(ONBOARDING_ANALYTICS.COMPLETED, { storageKey }); + onComplete?.(); + }, [storageKey, updateState, onComplete]); + + const skipWizard = useCallback(() => { + setIsWizardVisible(false); + setCurrentStep(0); + updateState({ + skipped: true, + skippedAt: new Date().toISOString(), + }); + trackAnalytics(ONBOARDING_ANALYTICS.SKIPPED, { + storageKey, + skippedAtStep: currentStep, + }); + onSkip?.(); + }, [storageKey, currentStep, updateState, onSkip]); + + return { + // Visibility + isVisible: isWizardVisible, + + // Steps + currentStep, + currentStepData, + totalSteps, + + // Navigation + goToNextStep, + goToPreviousStep, + goToStep, + + // Actions + startWizard, + completeWizard, + skipWizard, + + // State + isCompleted: onboardingState.completed, + isSkipped: onboardingState.skipped, + }; +} diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 054314455..741ef5074 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1032,17 +1032,9 @@ export function BoardView() { currentProject, }); - // Use onboarding wizard hook - check if board is empty (no non-sample features) - const nonSampleFeatureCount = useMemo( - () => hookFeatures.filter((f) => !isSampleFeature(f)).length, - [hookFeatures] - ); + // Use onboarding wizard hook - triggered manually via help button const onboarding = useBoardOnboarding({ projectPath: currentProject?.path || null, - isEmpty: nonSampleFeatureCount === 0 && !isLoading, - totalFeatureCount: hookFeatures.length, - // Don't show wizard when spec generation is happening (for new projects) - isSpecDialogOpen: isCreatingSpec, }); // Handler for Quick Start - create sample features @@ -1292,8 +1284,7 @@ export function BoardView() { onShowBoardBackground={() => setShowBoardBackgroundModal(true)} onShowCompletedModal={() => setShowCompletedModal(true)} completedCount={completedFeatures.length} - onShowTour={onboarding.retriggerWizard} - canShowTour={onboarding.canRetrigger} + onStartTour={onboarding.startWizard} /> {/* Worktree Panel - conditionally rendered based on visibility setting */} @@ -1667,6 +1658,7 @@ export function BoardView() { hasSampleData={onboarding.hasSampleData} onClearSampleData={handleClearSampleData} isQuickStartLoading={isQuickStartLoading} + steps={onboarding.steps} />
); diff --git a/apps/ui/src/components/views/board-view/board-controls.tsx b/apps/ui/src/components/views/board-view/board-controls.tsx index dacb38b6d..9bc66f9ae 100644 --- a/apps/ui/src/components/views/board-view/board-controls.tsx +++ b/apps/ui/src/components/views/board-view/board-controls.tsx @@ -7,10 +7,8 @@ interface BoardControlsProps { onShowBoardBackground: () => void; onShowCompletedModal: () => void; completedCount: number; - /** Callback to show the onboarding wizard tour */ - onShowTour?: () => void; - /** Whether the tour can be shown (wizard was previously completed/skipped) */ - canShowTour?: boolean; + /** Callback to start the onboarding wizard tour */ + onStartTour?: () => void; } export function BoardControls({ @@ -18,22 +16,21 @@ export function BoardControls({ onShowBoardBackground, onShowCompletedModal, completedCount, - onShowTour, - canShowTour = false, + onStartTour, }: BoardControlsProps) { if (!isMounted) return null; return (
- {/* Board Tour Button - only show if tour can be retriggered */} - {canShowTour && onShowTour && ( + {/* Board Tour Button - always visible when handler is provided */} + {onStartTour && (
diff --git a/apps/ui/src/components/views/board-view/components/board-onboarding-wizard.tsx b/apps/ui/src/components/views/board-view/components/board-onboarding-wizard.tsx index 141399c3f..6ac320986 100644 --- a/apps/ui/src/components/views/board-view/components/board-onboarding-wizard.tsx +++ b/apps/ui/src/components/views/board-view/components/board-onboarding-wizard.tsx @@ -1,80 +1,19 @@ /** * Board Onboarding Wizard Component * - * A multi-step wizard overlay that guides new users through the Kanban board - * workflow with visual highlighting (spotlight effect) on each column. - * - * Features: - * - Spotlight/overlay effect to focus attention on each column - * - Step navigation (Next, Previous, Skip) - * - Quick Start button to generate sample cards - * - Responsive design for mobile, tablet, and desktop - * - Keyboard navigation support + * Board-specific wrapper around the shared OnboardingWizard component. + * Adds Quick Start functionality to generate sample tasks. */ -import { useEffect, useRef, useCallback, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { - X, - ChevronLeft, - ChevronRight, - Sparkles, - PlayCircle, - Lightbulb, - CheckCircle2, - Trash2, - Loader2, - PartyPopper, - Settings2, -} from 'lucide-react'; +import { Sparkles, CheckCircle2, Trash2, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; -import { WIZARD_STEPS, type WizardStep } from '../hooks/use-board-onboarding'; - -// ============================================================================ -// CONSTANTS -// ============================================================================ - -/** Threshold for placing tooltip to the right of column (30% of viewport) */ -const TOOLTIP_POSITION_RIGHT_THRESHOLD = 0.3; - -/** Threshold for placing tooltip to the left of column (70% of viewport) */ -const TOOLTIP_POSITION_LEFT_THRESHOLD = 0.7; - -/** Padding around tooltip and highlight elements (px) */ -const SPOTLIGHT_PADDING = 8; - -/** Padding between column and tooltip (px) */ -const TOOLTIP_OFFSET = 16; - -/** Vertical offset from top of column to tooltip (px) */ -const TOOLTIP_TOP_OFFSET = 40; - -/** Maximum tooltip width (px) */ -const TOOLTIP_MAX_WIDTH = 400; - -/** Minimum safe margin from viewport edges (px) */ -const VIEWPORT_SAFE_MARGIN = 16; - -/** Threshold from bottom of viewport to trigger alternate positioning (px) */ -const BOTTOM_THRESHOLD = 450; - -/** Debounce delay for resize handler (ms) */ -const RESIZE_DEBOUNCE_MS = 100; - -/** Animation duration for step transitions (ms) */ -const STEP_TRANSITION_DURATION = 200; - -/** ID for the wizard description element (for aria-describedby) */ -const WIZARD_DESCRIPTION_ID = 'wizard-step-description'; - -/** ID for the wizard title element (for aria-labelledby) */ -const WIZARD_TITLE_ID = 'wizard-step-title'; +import { OnboardingWizard, type OnboardingStep } from '@/components/shared/onboarding'; interface BoardOnboardingWizardProps { isVisible: boolean; currentStep: number; - currentStepData: WizardStep | null; + currentStepData: OnboardingStep | null; totalSteps: number; onNext: () => void; onPrevious: () => void; @@ -84,16 +23,76 @@ interface BoardOnboardingWizardProps { hasSampleData: boolean; onClearSampleData: () => void; isQuickStartLoading?: boolean; + steps: OnboardingStep[]; } -// Icons for each column/step -const STEP_ICONS: Record> = { - backlog: PlayCircle, - in_progress: Sparkles, - waiting_approval: Lightbulb, - verified: CheckCircle2, - custom_columns: Settings2, -}; +/** + * Quick Start section component - only shown on first step + */ +function QuickStartSection({ + onQuickStart, + hasSampleData, + onClearSampleData, + isQuickStartLoading = false, +}: { + onQuickStart: () => void; + hasSampleData: boolean; + onClearSampleData: () => void; + isQuickStartLoading?: boolean; +}) { + return ( +
+

+

+

+ Want to see the board in action? We can add some sample tasks to demonstrate the workflow. +

+
+ + {hasSampleData && ( + + )} +
+
+ ); +} export function BoardOnboardingWizard({ isVisible, @@ -108,571 +107,31 @@ export function BoardOnboardingWizard({ hasSampleData, onClearSampleData, isQuickStartLoading = false, + steps, }: BoardOnboardingWizardProps) { - // Store rect as simple object to avoid DOMRect type issues - const [highlightRect, setHighlightRect] = useState<{ - top: number; - left: number; - right: number; - bottom: number; - width: number; - height: number; - } | null>(null); - const [tooltipPosition, setTooltipPosition] = useState<'left' | 'right' | 'bottom'>('bottom'); - const [isAnimating, setIsAnimating] = useState(false); - const [showCompletionCelebration, setShowCompletionCelebration] = useState(false); - - // Refs for focus management - const dialogRef = useRef(null); - const nextButtonRef = useRef(null); - - // Detect if user is on a touch device - const [isTouchDevice, setIsTouchDevice] = useState(false); - - useEffect(() => { - setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0); - }, []); - - // Lock scroll when wizard is visible - useEffect(() => { - if (!isVisible) return; - - // Prevent body scroll while wizard is open - const originalOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - - return () => { - document.body.style.overflow = originalOverflow; - }; - }, [isVisible]); - - // Focus management - move focus to dialog when opened - useEffect(() => { - if (!isVisible) return; - - // Focus the next button when wizard opens for keyboard accessibility - const timer = setTimeout(() => { - nextButtonRef.current?.focus(); - }, STEP_TRANSITION_DURATION); - - return () => clearTimeout(timer); - }, [isVisible]); - - // Animate step transitions - useEffect(() => { - if (!isVisible) return; - - setIsAnimating(true); - const timer = setTimeout(() => { - setIsAnimating(false); - }, STEP_TRANSITION_DURATION); - - return () => clearTimeout(timer); - }, [currentStep, isVisible]); - - // Find and highlight the current column - useEffect(() => { - if (!isVisible || !currentStepData) { - setHighlightRect(null); - return; - } - - // Helper to update highlight rect and tooltip position - const updateHighlight = () => { - const columnEl = document.querySelector( - `[data-testid="kanban-column-${currentStepData.columnId}"]` - ); - - if (columnEl) { - const rect = columnEl.getBoundingClientRect(); - setHighlightRect({ - top: rect.top, - left: rect.left, - right: rect.right, - bottom: rect.bottom, - width: rect.width, - height: rect.height, - }); - - // Determine tooltip position based on column position and available space - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const columnCenter = rect.left + rect.width / 2; - const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2); - - // Check if there's enough space at the bottom - const spaceAtBottom = viewportHeight - rect.bottom - TOOLTIP_OFFSET; - const spaceAtRight = viewportWidth - rect.right - TOOLTIP_OFFSET; - const spaceAtLeft = rect.left - TOOLTIP_OFFSET; - - // For leftmost columns, prefer right position - if ( - columnCenter < viewportWidth * TOOLTIP_POSITION_RIGHT_THRESHOLD && - spaceAtRight >= tooltipWidth - ) { - setTooltipPosition('right'); - } - // For rightmost columns, prefer left position - else if ( - columnCenter > viewportWidth * TOOLTIP_POSITION_LEFT_THRESHOLD && - spaceAtLeft >= tooltipWidth - ) { - setTooltipPosition('left'); - } - // For middle columns, check if bottom position would work - else if (spaceAtBottom >= BOTTOM_THRESHOLD) { - setTooltipPosition('bottom'); - } - // If bottom doesn't have enough space, try left or right based on which has more space - else if (spaceAtRight > spaceAtLeft && spaceAtRight >= tooltipWidth * 0.6) { - setTooltipPosition('right'); - } else if (spaceAtLeft >= tooltipWidth * 0.6) { - setTooltipPosition('left'); - } - // Fallback to bottom with scrollable content - else { - setTooltipPosition('bottom'); - } - } - }; - - // Initial update - updateHighlight(); - - // Debounced resize handler for performance - let resizeTimeout: ReturnType; - const handleResize = () => { - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(updateHighlight, RESIZE_DEBOUNCE_MS); - }; - - window.addEventListener('resize', handleResize); - return () => { - window.removeEventListener('resize', handleResize); - clearTimeout(resizeTimeout); - }; - }, [isVisible, currentStepData]); - - // Keyboard navigation - useEffect(() => { - if (!isVisible) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onSkip(); - } else if (e.key === 'ArrowRight' || e.key === 'Enter') { - if (currentStep < totalSteps - 1) { - onNext(); - } else { - onComplete(); - } - } else if (e.key === 'ArrowLeft') { - onPrevious(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isVisible, currentStep, totalSteps, onNext, onPrevious, onSkip, onComplete]); - - // Calculate tooltip styles based on position and highlight rect - const getTooltipStyles = useCallback((): React.CSSProperties => { - if (!highlightRect) return {}; - - const viewportHeight = window.innerHeight; - const viewportWidth = window.innerWidth; - const tooltipWidth = Math.min(TOOLTIP_MAX_WIDTH, viewportWidth - VIEWPORT_SAFE_MARGIN * 2); - - switch (tooltipPosition) { - case 'right': { - const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET); - const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN; - return { - position: 'fixed', - top: topPos, - left: highlightRect.right + TOOLTIP_OFFSET, - width: tooltipWidth, - maxWidth: `calc(100vw - ${highlightRect.right + TOOLTIP_OFFSET * 2}px)`, - maxHeight: Math.max(200, availableHeight), - }; - } - case 'left': { - const topPos = Math.max(VIEWPORT_SAFE_MARGIN, highlightRect.top + TOOLTIP_TOP_OFFSET); - const availableHeight = viewportHeight - topPos - VIEWPORT_SAFE_MARGIN; - return { - position: 'fixed', - top: topPos, - right: viewportWidth - highlightRect.left + TOOLTIP_OFFSET, - width: tooltipWidth, - maxWidth: `calc(${highlightRect.left - TOOLTIP_OFFSET * 2}px)`, - maxHeight: Math.max(200, availableHeight), - }; - } - case 'bottom': - default: { - // Calculate available space at bottom - const idealTop = highlightRect.bottom + TOOLTIP_OFFSET; - const availableHeight = viewportHeight - idealTop - VIEWPORT_SAFE_MARGIN; - - // If not enough space, position higher but ensure tooltip stays below header - const minTop = 100; // Minimum distance from top of viewport - const topPos = - availableHeight < 250 - ? Math.max( - minTop, - viewportHeight - Math.max(300, availableHeight) - VIEWPORT_SAFE_MARGIN - ) - : idealTop; - - // Center tooltip under column but keep within viewport bounds - const idealLeft = highlightRect.left + highlightRect.width / 2 - tooltipWidth / 2; - const leftPos = Math.max( - VIEWPORT_SAFE_MARGIN, - Math.min(idealLeft, viewportWidth - tooltipWidth - VIEWPORT_SAFE_MARGIN) - ); - - return { - position: 'fixed', - top: topPos, - left: leftPos, - width: tooltipWidth, - maxHeight: Math.max(200, viewportHeight - topPos - VIEWPORT_SAFE_MARGIN), - }; - } - } - }, [highlightRect, tooltipPosition]); - - // Handle completion with celebration - const handleComplete = useCallback(() => { - setShowCompletionCelebration(true); - // Show celebration briefly before completing - setTimeout(() => { - setShowCompletionCelebration(false); - onComplete(); - }, 1200); - }, [onComplete]); - - // Handle step indicator click for direct navigation - const handleStepClick = useCallback( - (stepIndex: number) => { - if (stepIndex === currentStep) return; - - // Use onNext/onPrevious to properly track analytics - if (stepIndex > currentStep) { - for (let i = currentStep; i < stepIndex; i++) { - onNext(); - } - } else { - for (let i = currentStep; i > stepIndex; i--) { - onPrevious(); - } - } - }, - [currentStep, onNext, onPrevious] - ); - - if (!isVisible || !currentStepData) return null; - - const StepIcon = STEP_ICONS[currentStepData.id] || Sparkles; - const isLastStep = currentStep === totalSteps - 1; const isFirstStep = currentStep === 0; - const content = ( -
- {/* Completion celebration overlay */} - {showCompletionCelebration && ( -
-
- -

You're all set!

-
-
- )} - - {/* Dark overlay with cutout for highlighted column */} - - - {/* Highlight border around the column */} - {highlightRect && ( -
)} - - {/* Skip button - top right with accessible touch target */} - - - {/* Tooltip/Card with step content */} -
- {/* Header */} -
-
- -
-
-

- {currentStepData.title} -

-
- - Step {currentStep + 1} of {totalSteps} - - {/* Step indicators - clickable for navigation */} - -
-
-
- - {/* Description */} -

- {currentStepData.description} -

- - {/* Tip box */} - {currentStepData.tip && ( -
-

- Tip: - {currentStepData.tip} -

-
- )} - - {/* Quick Start section - only on first step */} - {isFirstStep && ( -
-

-

-

- Want to see the board in action? We can add some sample tasks to demonstrate the - workflow. -

-
- - {hasSampleData && ( - - )} -
-
- )} - - {/* Navigation buttons */} -
- - - -
- - {/* Keyboard hints - hidden on touch devices for cleaner mobile UX */} - {!isTouchDevice && ( - - )} -
-
+ ); - - // Render in a portal to ensure it's above everything - return createPortal(content, document.body); } diff --git a/apps/ui/src/components/views/board-view/components/kanban-column.tsx b/apps/ui/src/components/views/board-view/components/kanban-column.tsx index 4a1b62dd7..0a9db2d14 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-column.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-column.tsx @@ -50,6 +50,7 @@ export const KanbanColumn = memo(function KanbanColumn({ )} style={widthStyle} data-testid={`kanban-column-${id}`} + data-onboarding-target={id} > {/* Background layer with opacity */}
): void { logger.debug(`[Analytics] ${event}`, data); - // TODO: Integrate with actual analytics service (e.g., PostHog, Amplitude) - // Example: posthog.capture(event, data); } +// ============================================================================ +// HOOK +// ============================================================================ + export interface UseBoardOnboardingOptions { projectPath: string | null; - isEmpty: boolean; // Whether the board has no features - totalFeatureCount: number; // Total number of features in the board - /** Whether the spec generation dialog is currently open (prevents wizard from showing) */ - isSpecDialogOpen?: boolean; } export interface UseBoardOnboardingResult { - // Wizard visibility + // From shared wizard hook isWizardVisible: boolean; - shouldShowWizard: boolean; - - // Current step currentStep: number; - currentStepData: WizardStep | null; + currentStepData: OnboardingStep | null; totalSteps: number; - - // Navigation goToNextStep: () => void; goToPreviousStep: () => void; goToStep: (step: number) => void; - - // Actions startWizard: () => void; completeWizard: () => void; skipWizard: () => void; - dismissWizard: () => void; + isCompleted: boolean; + isSkipped: boolean; - // Quick Start / Sample Data + // Board-specific hasSampleData: boolean; setHasSampleData: (has: boolean) => void; markQuickStartUsed: () => void; - // Re-trigger - canRetrigger: boolean; - retriggerWizard: () => void; - - // State - isCompleted: boolean; - isSkipped: boolean; + // Steps data for component + steps: OnboardingStep[]; } export function useBoardOnboarding({ projectPath, - isEmpty, - totalFeatureCount, - isSpecDialogOpen = false, }: UseBoardOnboardingOptions): UseBoardOnboardingResult { - // Local state - const [currentStep, setCurrentStep] = useState(0); - const [isWizardActive, setIsWizardActive] = useState(false); - const [onboardingState, setOnboardingState] = useState(DEFAULT_ONBOARDING_STATE); + // Board-specific state for sample data + const [boardData, setBoardData] = useState(DEFAULT_BOARD_DATA); + + // Create storage key from project path + const storageKey = projectPath ? `board:${sanitizeProjectPath(projectPath)}` : 'board:default'; + + // Use the shared onboarding wizard hook + const wizard = useOnboardingWizard({ + storageKey, + steps: BOARD_WIZARD_STEPS, + }); - // Load persisted state when project changes + // Load board-specific data when project changes useEffect(() => { if (!projectPath) { - setOnboardingState(DEFAULT_ONBOARDING_STATE); + setBoardData(DEFAULT_BOARD_DATA); return; } - const state = loadOnboardingState(projectPath); - setOnboardingState(state); - - // Auto-show wizard for empty boards that haven't seen it - // Don't re-trigger if board became empty after having features (edge case) - // Don't show if spec dialog is open (for new projects) - if ( - isEmpty && - !state.hasEverSeenWizard && - !state.completed && - !state.skipped && - !isSpecDialogOpen - ) { - // Small delay to let the board render first - const timer = setTimeout(() => { - setIsWizardActive(true); - trackAnalytics(ONBOARDING_ANALYTICS.STARTED, { projectPath }); - }, WIZARD_AUTO_SHOW_DELAY_MS); - return () => clearTimeout(timer); - } - }, [projectPath, isEmpty, isSpecDialogOpen]); + const data = loadBoardData(projectPath); + setBoardData(data); + }, [projectPath]); - // Update persisted state helper - const updateState = useCallback( - (updates: Partial) => { + // Update board data helper + const updateBoardData = useCallback( + (updates: Partial) => { if (!projectPath) return; - setOnboardingState((prev) => { - const newState = { ...prev, ...updates }; - saveOnboardingState(projectPath, newState); - return newState; + setBoardData((prev) => { + const newData = { ...prev, ...updates }; + saveBoardData(projectPath, newData); + return newData; }); }, [projectPath] ); - // Determine if wizard should be visible - // Don't show if: - // - No project selected - // - Already completed or skipped - // - Board has features and user has seen wizard before (became empty after deletion) - const shouldShowWizard = useMemo(() => { - if (!projectPath) return false; - if (onboardingState.completed || onboardingState.skipped) return false; - if (!isEmpty && onboardingState.hasEverSeenWizard) return false; - return isEmpty && !onboardingState.hasEverSeenWizard; - }, [projectPath, isEmpty, onboardingState]); - - // Current step data - const currentStepData = WIZARD_STEPS[currentStep] || null; - const totalSteps = WIZARD_STEPS.length; - - // Navigation handlers - const goToNextStep = useCallback(() => { - if (currentStep < totalSteps - 1) { - const nextStep = currentStep + 1; - setCurrentStep(nextStep); - trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, { - step: nextStep, - stepId: WIZARD_STEPS[nextStep]?.id, - projectPath, - }); - } - }, [currentStep, totalSteps, projectPath]); - - const goToPreviousStep = useCallback(() => { - if (currentStep > 0) { - setCurrentStep(currentStep - 1); - } - }, [currentStep]); - - const goToStep = useCallback( - (step: number) => { - if (step >= 0 && step < totalSteps) { - setCurrentStep(step); - trackAnalytics(ONBOARDING_ANALYTICS.STEP_VIEWED, { - step, - stepId: WIZARD_STEPS[step]?.id, - projectPath, - }); - } - }, - [totalSteps, projectPath] - ); - - // Wizard lifecycle handlers - const startWizard = useCallback(() => { - setCurrentStep(0); - setIsWizardActive(true); - updateState({ hasEverSeenWizard: true }); - trackAnalytics(ONBOARDING_ANALYTICS.STARTED, { projectPath }); - }, [projectPath, updateState]); - - const completeWizard = useCallback(() => { - setIsWizardActive(false); - setCurrentStep(0); - updateState({ - completed: true, - completedAt: new Date().toISOString(), - hasEverSeenWizard: true, - }); - trackAnalytics(ONBOARDING_ANALYTICS.COMPLETED, { - projectPath, - quickStartUsed: onboardingState.quickStartUsed, - totalFeatureCount, - }); - }, [projectPath, updateState, onboardingState.quickStartUsed, totalFeatureCount]); - - const skipWizard = useCallback(() => { - setIsWizardActive(false); - setCurrentStep(0); - updateState({ - skipped: true, - skippedAt: new Date().toISOString(), - hasEverSeenWizard: true, - }); - trackAnalytics(ONBOARDING_ANALYTICS.SKIPPED, { - projectPath, - skippedAtStep: currentStep, - }); - }, [projectPath, currentStep, updateState]); - - const dismissWizard = useCallback(() => { - // Same as skip but doesn't mark as "skipped" - just closes the wizard - setIsWizardActive(false); - updateState({ hasEverSeenWizard: true }); - }, [updateState]); - - // Quick Start / Sample Data + // Sample data handlers const setHasSampleData = useCallback( (has: boolean) => { - updateState({ hasSampleData: has }); + updateBoardData({ hasSampleData: has }); if (!has) { - trackAnalytics(ONBOARDING_ANALYTICS.SAMPLE_DATA_CLEARED, { projectPath }); + trackAnalytics(BOARD_ONBOARDING_ANALYTICS.SAMPLE_DATA_CLEARED, { projectPath }); } }, - [projectPath, updateState] + [projectPath, updateBoardData] ); const markQuickStartUsed = useCallback(() => { - updateState({ quickStartUsed: true, hasSampleData: true }); - trackAnalytics(ONBOARDING_ANALYTICS.QUICK_START_USED, { projectPath }); - }, [projectPath, updateState]); - - // Re-trigger wizard - memoized for stable reference - const canRetrigger = useMemo( - () => onboardingState.completed || onboardingState.skipped, - [onboardingState.completed, onboardingState.skipped] - ); - - const retriggerWizard = useCallback(() => { - setCurrentStep(0); - setIsWizardActive(true); - // Don't reset completion status, just show wizard again - trackAnalytics(ONBOARDING_ANALYTICS.RETRIGGERED, { projectPath }); - }, [projectPath]); + updateBoardData({ quickStartUsed: true, hasSampleData: true }); + trackAnalytics(BOARD_ONBOARDING_ANALYTICS.QUICK_START_USED, { projectPath }); + }, [projectPath, updateBoardData]); return { - // Visibility - isWizardVisible: isWizardActive, - shouldShowWizard, - - // Steps - currentStep, - currentStepData, - totalSteps, - - // Navigation - goToNextStep, - goToPreviousStep, - goToStep, - - // Actions - startWizard, - completeWizard, - skipWizard, - dismissWizard, - - // Sample Data - hasSampleData: onboardingState.hasSampleData, + // Spread shared wizard state and actions + isWizardVisible: wizard.isVisible, + currentStep: wizard.currentStep, + currentStepData: wizard.currentStepData, + totalSteps: wizard.totalSteps, + goToNextStep: wizard.goToNextStep, + goToPreviousStep: wizard.goToPreviousStep, + goToStep: wizard.goToStep, + startWizard: wizard.startWizard, + completeWizard: wizard.completeWizard, + skipWizard: wizard.skipWizard, + isCompleted: wizard.isCompleted, + isSkipped: wizard.isSkipped, + + // Board-specific + hasSampleData: boardData.hasSampleData, setHasSampleData, markQuickStartUsed, - // Re-trigger - canRetrigger, - retriggerWizard, - - // State - isCompleted: onboardingState.completed, - isSkipped: onboardingState.skipped, + // Steps data + steps: BOARD_WIZARD_STEPS, }; } diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 4601a70c5..ca055d532 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -173,6 +173,7 @@ export function KanbanBoard({ onClick={onOpenPipelineSettings} title="Pipeline Settings" data-testid="pipeline-settings-button" + data-onboarding-target="pipeline-settings" >