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.
This commit is contained in:
Kacper
2026-01-11 16:05:04 +01:00
parent 66b68ea4eb
commit d4ce1f331b
13 changed files with 1201 additions and 928 deletions

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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<HighlightRect | 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<HTMLDivElement>(null);
const nextButtonRef = useRef<HTMLButtonElement>(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<typeof setTimeout>;
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 = (
<div
ref={dialogRef}
className="fixed inset-0 z-[100]"
role="dialog"
aria-modal="true"
aria-labelledby={WIZARD_TITLE_ID}
aria-describedby={WIZARD_DESCRIPTION_ID}
>
{/* Completion celebration overlay */}
{showCompletionCelebration && (
<div className="absolute inset-0 z-[102] flex items-center justify-center pointer-events-none">
<div className="animate-in zoom-in-50 fade-in duration-300 flex flex-col items-center gap-4 text-white">
<PartyPopper className="w-16 h-16 text-yellow-400 animate-bounce" />
<p className="text-2xl font-bold">You're all set!</p>
</div>
</div>
)}
{/* Dark overlay with cutout for highlighted element */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<mask id="spotlight-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
{highlightRect && (
<rect
x={highlightRect.left - SPOTLIGHT_PADDING}
y={highlightRect.top - SPOTLIGHT_PADDING}
width={highlightRect.width + SPOTLIGHT_PADDING * 2}
height={highlightRect.height + SPOTLIGHT_PADDING * 2}
rx="16"
fill="black"
/>
)}
</mask>
</defs>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="rgba(0, 0, 0, 0.75)"
mask="url(#spotlight-mask)"
className="transition-all duration-300"
/>
</svg>
{/* Highlight border around the target element */}
{highlightRect && (
<div
className="absolute pointer-events-none transition-all duration-300 ease-out"
style={{
left: highlightRect.left - SPOTLIGHT_PADDING,
top: highlightRect.top - SPOTLIGHT_PADDING,
width: highlightRect.width + SPOTLIGHT_PADDING * 2,
height: highlightRect.height + SPOTLIGHT_PADDING * 2,
borderRadius: '16px',
border: '2px solid hsl(var(--primary))',
boxShadow:
'0 0 20px 4px hsl(var(--primary) / 0.3), inset 0 0 20px 4px hsl(var(--primary) / 0.1)',
}}
/>
)}
{/* Skip button - top right */}
<Button
variant="ghost"
size="sm"
className={cn(
'fixed top-4 right-4 z-[101]',
'text-white/70 hover:text-white hover:bg-white/10',
'focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-transparent',
'min-h-[44px] min-w-[44px] px-3'
)}
onClick={onSkip}
aria-label="Skip the onboarding tour"
>
<X className="w-4 h-4 mr-1.5" aria-hidden="true" />
<span>Skip Tour</span>
</Button>
{/* Tooltip card with step content */}
<div
className={cn(
'z-[101] bg-popover/95 backdrop-blur-xl rounded-xl shadow-2xl border border-border/50',
'p-6 animate-in fade-in-0 slide-in-from-bottom-4 duration-300',
'max-h-[calc(100vh-100px)] overflow-y-auto',
isAnimating && 'opacity-90 scale-[0.98]',
'transition-all duration-200 ease-out'
)}
style={getTooltipStyles()}
>
{/* Header */}
<div className="flex items-start gap-4 mb-4">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 border border-primary/20 shrink-0">
<StepIcon className="w-6 h-6 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 id={WIZARD_TITLE_ID} className="text-lg font-semibold text-foreground truncate">
{currentStepData.title}
</h3>
<div className="flex items-center gap-2 mt-1.5">
<span className="text-xs text-muted-foreground" aria-live="polite">
Step {currentStep + 1} of {totalSteps}
</span>
{/* Step indicators - clickable for navigation */}
<nav aria-label="Wizard steps" className="flex items-center gap-1">
{Array.from({ length: totalSteps }).map((_, i) => (
<button
key={i}
type="button"
onClick={() => handleStepClick(i)}
className={cn(
'relative flex items-center justify-center',
'w-6 h-6',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:rounded-full',
'transition-transform duration-200 hover:scale-110'
)}
aria-label={`Go to step ${i + 1}: ${steps[i]?.title}`}
aria-current={i === currentStep ? 'step' : undefined}
>
<span
className={cn(
'block rounded-full transition-all duration-200',
i === currentStep
? 'w-2.5 h-2.5 bg-primary ring-2 ring-primary/30 ring-offset-1 ring-offset-popover'
: i < currentStep
? 'w-2 h-2 bg-primary/60'
: 'w-2 h-2 bg-muted-foreground/40'
)}
/>
</button>
))}
</nav>
</div>
</div>
</div>
{/* Description */}
<p
id={WIZARD_DESCRIPTION_ID}
className="text-sm text-muted-foreground leading-relaxed mb-4"
>
{currentStepData.description}
</p>
{/* Tip box */}
{currentStepData.tip && (
<div className="rounded-lg bg-primary/5 border border-primary/10 p-3 mb-4">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">Tip: </span>
{currentStepData.tip}
</p>
</div>
)}
{/* Custom content slot (e.g., Quick Start section) */}
{children}
{/* Navigation buttons */}
<div className="flex items-center justify-between gap-3 pt-2">
<Button
variant="ghost"
size="sm"
onClick={onPrevious}
disabled={isFirstStep}
className={cn(
'text-muted-foreground min-h-[44px]',
'focus-visible:ring-2 focus-visible:ring-primary',
isFirstStep && 'invisible'
)}
aria-label="Go to previous step"
>
<ChevronLeft className="w-4 h-4 mr-1" aria-hidden="true" />
<span>Previous</span>
</Button>
<Button
ref={nextButtonRef}
size="sm"
onClick={isLastStep ? handleComplete : onNext}
disabled={showCompletionCelebration}
className={cn(
'bg-primary hover:bg-primary/90 text-primary-foreground',
'min-w-[120px] min-h-[44px]',
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
'transition-all duration-200'
)}
aria-label={isLastStep ? 'Complete the tour and get started' : 'Go to next step'}
>
{isLastStep ? (
<>
<span>Get Started</span>
<CheckCircle2 className="w-4 h-4 ml-1.5" aria-hidden="true" />
</>
) : (
<>
<span>Next</span>
<ChevronRight className="w-4 h-4 ml-1" aria-hidden="true" />
</>
)}
</Button>
</div>
{/* Keyboard hints - hidden on touch devices */}
{!isTouchDevice && (
<div
className="mt-4 pt-3 border-t border-border/50 flex items-center justify-center gap-4 text-xs text-muted-foreground/70"
aria-hidden="true"
>
<span className="flex items-center gap-1.5">
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
ESC
</kbd>
<span>to skip</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
</kbd>
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
</kbd>
<span>to navigate</span>
</span>
</div>
)}
</div>
</div>
);
// Render in a portal to ensure it's above everything
return createPortal(content, document.body);
}

View File

@@ -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;
}

View File

@@ -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<string, unknown>): 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
* <button onClick={wizard.startWizard}>Help</button>
*
* // Render the wizard
* <OnboardingWizard
* isVisible={wizard.isVisible}
* currentStep={wizard.currentStep}
* currentStepData={wizard.currentStepData}
* totalSteps={wizard.totalSteps}
* onNext={wizard.goToNextStep}
* onPrevious={wizard.goToPreviousStep}
* onSkip={wizard.skipWizard}
* onComplete={wizard.completeWizard}
* steps={MY_WIZARD_STEPS}
* />
* ```
*/
export function useOnboardingWizard({
storageKey,
steps,
onComplete,
onSkip,
}: UseOnboardingWizardOptions): UseOnboardingWizardResult {
const [currentStep, setCurrentStep] = useState(0);
const [isWizardVisible, setIsWizardVisible] = useState(false);
const [onboardingState, setOnboardingState] = useState<OnboardingState>(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<OnboardingState>) => {
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,
};
}

View File

@@ -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}
/>
</div>
);

View File

@@ -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 (
<TooltipProvider>
<div className="flex items-center gap-2">
{/* Board Tour Button - only show if tour can be retriggered */}
{canShowTour && onShowTour && (
{/* Board Tour Button - always visible when handler is provided */}
{onStartTour && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onShowTour}
onClick={onStartTour}
className="h-8 px-2 min-w-[32px] focus-visible:ring-2 focus-visible:ring-primary"
data-testid="board-tour-button"
aria-label="Take a board tour - learn how to use the kanban board"

View File

@@ -32,8 +32,7 @@ interface BoardHeaderProps {
onShowCompletedModal: () => void;
completedCount: number;
// Tour/onboarding props
onShowTour?: () => void;
canShowTour?: boolean;
onStartTour?: () => void;
}
// Shared styles for header control containers
@@ -56,8 +55,7 @@ export function BoardHeader({
onShowBoardBackground,
onShowCompletedModal,
completedCount,
onShowTour,
canShowTour,
onStartTour,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys);
@@ -118,8 +116,7 @@ export function BoardHeader({
onShowBoardBackground={onShowBoardBackground}
onShowCompletedModal={onShowCompletedModal}
completedCount={completedCount}
onShowTour={onShowTour}
canShowTour={canShowTour}
onStartTour={onStartTour}
/>
</div>
<div className="flex gap-2 items-center">

View File

@@ -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<string, React.ComponentType<{ className?: string }>> = {
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 (
<div className="rounded-lg bg-muted/30 border border-border/50 p-4 mb-4">
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary" aria-hidden="true" />
Quick Start
</h4>
<p className="text-xs text-muted-foreground mb-3">
Want to see the board in action? We can add some sample tasks to demonstrate the workflow.
</p>
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
onClick={onQuickStart}
disabled={hasSampleData || isQuickStartLoading}
className={cn('flex-1 min-h-[40px]', 'focus-visible:ring-2 focus-visible:ring-primary')}
aria-busy={isQuickStartLoading}
>
{isQuickStartLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" aria-hidden="true" />
<span>Adding tasks...</span>
</>
) : hasSampleData ? (
<>
<CheckCircle2 className="w-3.5 h-3.5 mr-1.5 text-green-500" aria-hidden="true" />
<span>Sample Data Added</span>
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5 mr-1.5" aria-hidden="true" />
<span>Add Sample Tasks</span>
</>
)}
</Button>
{hasSampleData && (
<Button
size="sm"
variant="ghost"
onClick={onClearSampleData}
className={cn(
'min-w-[44px] min-h-[40px] px-3',
'focus-visible:ring-2 focus-visible:ring-destructive'
)}
aria-label="Remove sample tasks"
>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</Button>
)}
</div>
</div>
);
}
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<HTMLDivElement>(null);
const nextButtonRef = useRef<HTMLButtonElement>(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<typeof setTimeout>;
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 = (
<div
ref={dialogRef}
className="fixed inset-0 z-[100]"
role="dialog"
aria-modal="true"
aria-labelledby={WIZARD_TITLE_ID}
aria-describedby={WIZARD_DESCRIPTION_ID}
return (
<OnboardingWizard
isVisible={isVisible}
currentStep={currentStep}
currentStepData={currentStepData}
totalSteps={totalSteps}
onNext={onNext}
onPrevious={onPrevious}
onSkip={onSkip}
onComplete={onComplete}
steps={steps}
>
{/* Completion celebration overlay */}
{showCompletionCelebration && (
<div className="absolute inset-0 z-[102] flex items-center justify-center pointer-events-none">
<div className="animate-in zoom-in-50 fade-in duration-300 flex flex-col items-center gap-4 text-white">
<PartyPopper className="w-16 h-16 text-yellow-400 animate-bounce" />
<p className="text-2xl font-bold">You're all set!</p>
</div>
</div>
)}
{/* Dark overlay with cutout for highlighted column */}
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<defs>
<mask id="spotlight-mask">
{/* White = visible, black = hidden */}
<rect x="0" y="0" width="100%" height="100%" fill="white" />
{highlightRect && (
<rect
x={highlightRect.left - SPOTLIGHT_PADDING}
y={highlightRect.top - SPOTLIGHT_PADDING}
width={highlightRect.width + SPOTLIGHT_PADDING * 2}
height={highlightRect.height + SPOTLIGHT_PADDING * 2}
rx="16"
fill="black"
/>
)}
</mask>
</defs>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="rgba(0, 0, 0, 0.75)"
mask="url(#spotlight-mask)"
className="transition-all duration-300"
/>
</svg>
{/* Highlight border around the column */}
{highlightRect && (
<div
className="absolute pointer-events-none transition-all duration-300 ease-out"
style={{
left: highlightRect.left - SPOTLIGHT_PADDING,
top: highlightRect.top - SPOTLIGHT_PADDING,
width: highlightRect.width + SPOTLIGHT_PADDING * 2,
height: highlightRect.height + SPOTLIGHT_PADDING * 2,
borderRadius: '16px',
border: '2px solid hsl(var(--primary))',
boxShadow:
'0 0 20px 4px hsl(var(--primary) / 0.3), inset 0 0 20px 4px hsl(var(--primary) / 0.1)',
}}
{/* Board-specific Quick Start section - only on first step */}
{isFirstStep && (
<QuickStartSection
onQuickStart={onQuickStart}
hasSampleData={hasSampleData}
onClearSampleData={onClearSampleData}
isQuickStartLoading={isQuickStartLoading}
/>
)}
{/* Skip button - top right with accessible touch target */}
<Button
variant="ghost"
size="sm"
className={cn(
'fixed top-4 right-4 z-[101]',
'text-white/70 hover:text-white hover:bg-white/10',
'focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-transparent',
'min-h-[44px] min-w-[44px] px-3' // Ensure minimum touch target size
)}
onClick={onSkip}
aria-label="Skip the onboarding tour"
>
<X className="w-4 h-4 mr-1.5" aria-hidden="true" />
<span>Skip Tour</span>
</Button>
{/* Tooltip/Card with step content */}
<div
className={cn(
'z-[101] bg-popover/95 backdrop-blur-xl rounded-xl shadow-2xl border border-border/50',
'p-6 animate-in fade-in-0 slide-in-from-bottom-4 duration-300',
'max-h-[calc(100vh-100px)] overflow-y-auto',
// Step transition animation
isAnimating && 'opacity-90 scale-[0.98]',
'transition-all duration-200 ease-out'
)}
style={getTooltipStyles()}
>
{/* Header */}
<div className="flex items-start gap-4 mb-4">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 border border-primary/20 shrink-0">
<StepIcon className="w-6 h-6 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 id={WIZARD_TITLE_ID} className="text-lg font-semibold text-foreground truncate">
{currentStepData.title}
</h3>
<div className="flex items-center gap-2 mt-1.5">
<span className="text-xs text-muted-foreground" aria-live="polite">
Step {currentStep + 1} of {totalSteps}
</span>
{/* Step indicators - clickable for navigation */}
<nav aria-label="Wizard steps" className="flex items-center gap-1">
{Array.from({ length: totalSteps }).map((_, i) => (
<button
key={i}
type="button"
onClick={() => handleStepClick(i)}
className={cn(
'relative flex items-center justify-center',
'w-6 h-6', // Touch target size
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:rounded-full',
'transition-transform duration-200 hover:scale-110'
)}
aria-label={`Go to step ${i + 1}: ${WIZARD_STEPS[i]?.title}`}
aria-current={i === currentStep ? 'step' : undefined}
>
{/* Visual dot indicator */}
<span
className={cn(
'block rounded-full transition-all duration-200',
i === currentStep
? 'w-2.5 h-2.5 bg-primary ring-2 ring-primary/30 ring-offset-1 ring-offset-popover'
: i < currentStep
? 'w-2 h-2 bg-primary/60'
: 'w-2 h-2 bg-muted-foreground/40'
)}
/>
</button>
))}
</nav>
</div>
</div>
</div>
{/* Description */}
<p
id={WIZARD_DESCRIPTION_ID}
className="text-sm text-muted-foreground leading-relaxed mb-4"
>
{currentStepData.description}
</p>
{/* Tip box */}
{currentStepData.tip && (
<div className="rounded-lg bg-primary/5 border border-primary/10 p-3 mb-4">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">Tip: </span>
{currentStepData.tip}
</p>
</div>
)}
{/* Quick Start section - only on first step */}
{isFirstStep && (
<div className="rounded-lg bg-muted/30 border border-border/50 p-4 mb-4">
<h4 className="text-sm font-medium text-foreground mb-2 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-primary" aria-hidden="true" />
Quick Start
</h4>
<p className="text-xs text-muted-foreground mb-3">
Want to see the board in action? We can add some sample tasks to demonstrate the
workflow.
</p>
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
onClick={onQuickStart}
disabled={hasSampleData || isQuickStartLoading}
className={cn(
'flex-1 min-h-[40px]', // Slightly larger touch target
'focus-visible:ring-2 focus-visible:ring-primary'
)}
aria-busy={isQuickStartLoading}
>
{isQuickStartLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" aria-hidden="true" />
<span>Adding tasks...</span>
</>
) : hasSampleData ? (
<>
<CheckCircle2
className="w-3.5 h-3.5 mr-1.5 text-green-500"
aria-hidden="true"
/>
<span>Sample Data Added</span>
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5 mr-1.5" aria-hidden="true" />
<span>Add Sample Tasks</span>
</>
)}
</Button>
{hasSampleData && (
<Button
size="sm"
variant="ghost"
onClick={onClearSampleData}
className={cn(
'min-w-[44px] min-h-[40px] px-3', // Accessible touch target
'focus-visible:ring-2 focus-visible:ring-destructive'
)}
aria-label="Remove sample tasks"
>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</Button>
)}
</div>
</div>
)}
{/* Navigation buttons */}
<div className="flex items-center justify-between gap-3 pt-2">
<Button
variant="ghost"
size="sm"
onClick={onPrevious}
disabled={isFirstStep}
className={cn(
'text-muted-foreground min-h-[44px]',
'focus-visible:ring-2 focus-visible:ring-primary',
isFirstStep && 'invisible'
)}
aria-label="Go to previous step"
>
<ChevronLeft className="w-4 h-4 mr-1" aria-hidden="true" />
<span>Previous</span>
</Button>
<Button
ref={nextButtonRef}
size="sm"
onClick={isLastStep ? handleComplete : onNext}
disabled={showCompletionCelebration}
className={cn(
'bg-primary hover:bg-primary/90 text-primary-foreground',
'min-w-[120px] min-h-[44px]', // Accessible touch target
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
'transition-all duration-200'
)}
aria-label={isLastStep ? 'Complete the tour and get started' : 'Go to next step'}
>
{isLastStep ? (
<>
<span>Get Started</span>
<CheckCircle2 className="w-4 h-4 ml-1.5" aria-hidden="true" />
</>
) : (
<>
<span>Next</span>
<ChevronRight className="w-4 h-4 ml-1" aria-hidden="true" />
</>
)}
</Button>
</div>
{/* Keyboard hints - hidden on touch devices for cleaner mobile UX */}
{!isTouchDevice && (
<div
className="mt-4 pt-3 border-t border-border/50 flex items-center justify-center gap-4 text-xs text-muted-foreground/70"
aria-hidden="true"
>
<span className="flex items-center gap-1.5">
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
ESC
</kbd>
<span>to skip</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
</kbd>
<kbd className="px-2 py-1 rounded bg-muted text-muted-foreground font-mono text-[11px] shadow-sm">
</kbd>
<span>to navigate</span>
</span>
</div>
)}
</div>
</div>
</OnboardingWizard>
);
// Render in a portal to ensure it's above everything
return createPortal(content, document.body);
}

View File

@@ -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 */}
<div

View File

@@ -1,19 +1,23 @@
/**
* Board Onboarding Hook
*
* Manages the state and logic for the interactive onboarding wizard
* that guides new users through the Kanban board workflow.
* Board-specific wrapper around the shared onboarding wizard hook.
* Manages board-specific features like sample data (Quick Start).
*
* Features:
* - Tracks wizard completion status per project
* - Persists state to localStorage (per user, per board)
* - Handles step navigation
* - Provides analytics tracking
* Usage:
* - Wizard is triggered manually via startWizard() when user clicks help button
* - No auto-show logic - user controls when to see the wizard
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getItem, setItem } from '@/lib/storage';
import {
useOnboardingWizard,
ONBOARDING_TARGET_ATTRIBUTE,
type OnboardingStep,
} from '@/components/shared/onboarding';
import { PlayCircle, Sparkles, Lightbulb, CheckCircle2, Settings2 } from 'lucide-react';
const logger = createLogger('BoardOnboarding');
@@ -21,389 +25,251 @@ 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;
/** Storage key prefix for board-specific onboarding data */
const BOARD_ONBOARDING_STORAGE_KEY = 'automaker:board-onboarding-data';
/** 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',
// Board-specific analytics events
export const BOARD_ONBOARDING_ANALYTICS = {
QUICK_START_USED: 'board_onboarding_quick_start_used',
SAMPLE_DATA_CLEARED: 'board_onboarding_sample_data_cleared',
} as const;
// Wizard step definitions
export interface WizardStep {
id: string;
columnId: string;
title: string;
description: string;
tip?: string;
}
// ============================================================================
// WIZARD STEPS
// ============================================================================
export const WIZARD_STEPS: WizardStep[] = [
/**
* Board wizard step definitions
* Each step targets a kanban column via data-onboarding-target
*/
export const BOARD_WIZARD_STEPS: OnboardingStep[] = [
{
id: 'backlog',
columnId: 'backlog',
targetId: '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.',
icon: PlayCircle,
},
{
id: 'in_progress',
columnId: 'in_progress',
targetId: '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.',
icon: Sparkles,
},
{
id: 'waiting_approval',
columnId: 'waiting_approval',
targetId: '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.',
icon: Lightbulb,
},
{
id: 'verified',
columnId: 'verified',
targetId: '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.',
icon: CheckCircle2,
},
{
id: 'custom_columns',
columnId: 'in_progress', // Highlight "In Progress" column to show the settings icon
targetId: 'pipeline-settings', // Highlight the pipeline settings button 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.',
'You can create custom columns (called pipelines) to build your own workflow! Click this settings icon to add, rename, or configure pipeline steps.',
tip: 'Use pipelines to add code review, QA testing, or any custom stage to your workflow.',
icon: Settings2,
},
];
// Persisted onboarding state structure
interface OnboardingState {
completed: boolean;
completedAt?: string;
skipped: boolean;
skippedAt?: string;
hasEverSeenWizard: boolean;
// Re-export for backward compatibility
export type { OnboardingStep as WizardStep } from '@/components/shared/onboarding';
export { ONBOARDING_TARGET_ATTRIBUTE };
// ============================================================================
// BOARD-SPECIFIC STATE
// ============================================================================
interface BoardOnboardingData {
hasSampleData: boolean;
quickStartUsed: boolean;
}
// Default state for new projects
const DEFAULT_ONBOARDING_STATE: OnboardingState = {
completed: false,
skipped: false,
hasEverSeenWizard: false,
const DEFAULT_BOARD_DATA: BoardOnboardingData = {
hasSampleData: false,
quickStartUsed: false,
};
/**
* Get storage key for a specific project
* Creates a sanitized key from the project path for localStorage
* Sanitize project path to create a storage key
*/
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}`;
function sanitizeProjectPath(projectPath: string): string {
return projectPath.replace(/[^a-zA-Z0-9]/g, '_').slice(0, PROJECT_PATH_HASH_MAX_LENGTH);
}
// Load onboarding state from localStorage
function loadOnboardingState(projectPath: string): OnboardingState {
/**
* Get storage key for board-specific data
*/
function getBoardDataStorageKey(projectPath: string): string {
const hash = sanitizeProjectPath(projectPath);
return `${BOARD_ONBOARDING_STORAGE_KEY}:${hash}`;
}
/**
* Load board-specific onboarding data from localStorage
*/
function loadBoardData(projectPath: string): BoardOnboardingData {
try {
const key = getStorageKey(projectPath);
const key = getBoardDataStorageKey(projectPath);
const stored = getItem(key);
if (stored) {
return JSON.parse(stored) as OnboardingState;
return JSON.parse(stored) as BoardOnboardingData;
}
} catch (error) {
logger.error('Failed to load onboarding state:', error);
logger.error('Failed to load board onboarding data:', error);
}
return { ...DEFAULT_ONBOARDING_STATE };
return { ...DEFAULT_BOARD_DATA };
}
// Save onboarding state to localStorage
function saveOnboardingState(projectPath: string, state: OnboardingState): void {
/**
* Save board-specific onboarding data to localStorage
*/
function saveBoardData(projectPath: string, data: BoardOnboardingData): void {
try {
const key = getStorageKey(projectPath);
setItem(key, JSON.stringify(state));
const key = getBoardDataStorageKey(projectPath);
setItem(key, JSON.stringify(data));
} catch (error) {
logger.error('Failed to save onboarding state:', error);
logger.error('Failed to save board onboarding data:', error);
}
}
// Track analytics event (placeholder - integrate with actual analytics service)
/**
* Track analytics event (placeholder)
*/
function trackAnalytics(event: string, data?: Record<string, unknown>): 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<OnboardingState>(DEFAULT_ONBOARDING_STATE);
// Board-specific state for sample data
const [boardData, setBoardData] = useState<BoardOnboardingData>(DEFAULT_BOARD_DATA);
// Load persisted state when project changes
// 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 board-specific data when project changes
useEffect(() => {
if (!projectPath) {
setOnboardingState(DEFAULT_ONBOARDING_STATE);
setBoardData(DEFAULT_BOARD_DATA);
return;
}
const state = loadOnboardingState(projectPath);
setOnboardingState(state);
const data = loadBoardData(projectPath);
setBoardData(data);
}, [projectPath]);
// 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<OnboardingState>) => {
// Update board data helper
const updateBoardData = useCallback(
(updates: Partial<BoardOnboardingData>) => {
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,
// 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,
// Steps
currentStep,
currentStepData,
totalSteps,
// Navigation
goToNextStep,
goToPreviousStep,
goToStep,
// Actions
startWizard,
completeWizard,
skipWizard,
dismissWizard,
// Sample Data
hasSampleData: onboardingState.hasSampleData,
// Board-specific
hasSampleData: boardData.hasSampleData,
setHasSampleData,
markQuickStartUsed,
// Re-trigger
canRetrigger,
retriggerWizard,
// State
isCompleted: onboardingState.completed,
isSkipped: onboardingState.skipped,
// Steps data
steps: BOARD_WIZARD_STEPS,
};
}

View File

@@ -173,6 +173,7 @@ export function KanbanBoard({
onClick={onOpenPipelineSettings}
title="Pipeline Settings"
data-testid="pipeline-settings-button"
data-onboarding-target="pipeline-settings"
>
<Settings2 className="w-3.5 h-3.5" />
</Button>