refactor: Extract WorkflowStepper sub-components and utilities

- Move WorkflowStepper into dedicated folder with sub-components
- Extract StepCircle, StepLabel, and StepPopover components
- Create workflowSteps constant for step definitions
- Extract getCurrentStep logic to workflowStepCalculation utility
- Extract getGuideContent logic to workflowGuideContent utility
- Reduce WorkflowStepper.tsx from 487 to 140 lines (71% reduction)

Part of #33: Extract sub-components from large components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-12-27 13:22:14 +01:00
parent ec19426dd1
commit 2de8cd12ff
9 changed files with 623 additions and 487 deletions

View file

@ -1,487 +0,0 @@
import { useState, useRef } from "react";
import { useClickOutside } from "@/hooks";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
import { usePatternStore } from "../stores/usePatternStore";
import {
CheckCircleIcon,
InformationCircleIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/solid";
import { MachineStatus } from "../types/machine";
interface Step {
id: number;
label: string;
description: string;
}
const steps: Step[] = [
{ id: 1, label: "Connect", description: "Connect to machine" },
{ id: 2, label: "Home Machine", description: "Initialize hoop position" },
{ id: 3, label: "Load Pattern", description: "Choose PES file" },
{ id: 4, label: "Upload", description: "Upload to machine" },
{ id: 5, label: "Mask Trace", description: "Trace pattern area" },
{ id: 6, label: "Start Sewing", description: "Begin embroidery" },
{ id: 7, label: "Monitor", description: "Watch progress" },
{ id: 8, label: "Complete", description: "Finish and remove" },
];
// Helper function to get guide content for a step
function getGuideContent(stepId: number, machineStatus: MachineStatus) {
// Return content based on step
switch (stepId) {
case 1:
return {
type: "info" as const,
title: "Step 1: Connect to Machine",
description:
"To get started, connect to your Brother embroidery machine via Bluetooth.",
items: [
"Make sure your machine is powered on",
"Enable Bluetooth on your machine",
'Click the "Connect to Machine" button below',
],
};
case 2:
return {
type: "info" as const,
title: "Step 2: Home Machine",
description:
"The hoop needs to be removed and an initial homing procedure must be performed.",
items: [
"Remove the embroidery hoop from the machine completely",
"Press the Accept button on the machine",
"Wait for the machine to complete its initialization (homing)",
"Once initialization is complete, reattach the hoop",
"The machine should now recognize the hoop correctly",
],
};
case 3:
return {
type: "info" as const,
title: "Step 3: Load Your Pattern",
description:
"Choose a PES embroidery file from your computer to preview and upload.",
items: [
'Click "Choose PES File" in the Pattern File section',
"Select your embroidery design (.pes file)",
"Review the pattern preview on the right",
"You can drag the pattern to adjust its position",
],
};
case 4:
return {
type: "info" as const,
title: "Step 4: Upload Pattern to Machine",
description:
"Send your pattern to the embroidery machine to prepare for sewing.",
items: [
"Review the pattern preview to ensure it's positioned correctly",
"Check the pattern size matches your hoop",
'Click "Upload to Machine" when ready',
"Wait for the upload to complete (this may take a minute)",
],
};
case 5:
// Check machine status for substates
if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) {
return {
type: "warning" as const,
title: "Machine Action Required",
description: "The machine is ready to trace the pattern outline.",
items: [
"Press the button on your machine to confirm and start the mask trace",
"Ensure the hoop is properly attached",
"Make sure the needle area is clear",
],
};
}
if (machineStatus === MachineStatus.MASK_TRACING) {
return {
type: "progress" as const,
title: "Mask Trace In Progress",
description:
"The machine is tracing the pattern boundary. Please wait...",
items: [
"Watch the machine trace the outline",
"Verify the pattern fits within your hoop",
"Do not interrupt the machine",
],
};
}
return {
type: "info" as const,
title: "Step 5: Start Mask Trace",
description:
"The mask trace helps the machine understand the pattern boundaries.",
items: [
'Click "Start Mask Trace" button in the Sewing Progress section',
"The machine will trace the pattern outline",
"This ensures the hoop is positioned correctly",
],
};
case 6:
return {
type: "success" as const,
title: "Step 6: Ready to Sew!",
description: "The machine is ready to begin embroidering your pattern.",
items: [
"Verify your thread colors are correct",
"Ensure the fabric is properly hooped",
'Click "Start Sewing" when ready',
],
};
case 7:
// Check for substates
if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) {
return {
type: "warning" as const,
title: "Thread Change Required",
description:
"The machine needs a different thread color to continue.",
items: [
"Check the color blocks section to see which thread is needed",
"Change to the correct thread color",
"Press the button on your machine to resume sewing",
],
};
}
if (
machineStatus === MachineStatus.PAUSE ||
machineStatus === MachineStatus.STOP ||
machineStatus === MachineStatus.SEWING_INTERRUPTION
) {
return {
type: "warning" as const,
title: "Sewing Paused",
description: "The embroidery has been paused or interrupted.",
items: [
"Check if everything is okay with the machine",
'Click "Resume Sewing" when ready to continue',
"The machine will pick up where it left off",
],
};
}
return {
type: "progress" as const,
title: "Step 7: Sewing In Progress",
description:
"Your embroidery is being stitched. Monitor the progress below.",
items: [
"Watch the progress bar and current stitch count",
"The machine will pause when a color change is needed",
"Do not leave the machine unattended",
],
};
case 8:
return {
type: "success" as const,
title: "Step 8: Embroidery Complete!",
description: "Your embroidery is finished. Great work!",
items: [
"Remove the hoop from the machine",
"Press the Accept button on the machine",
"Carefully remove your finished embroidery",
"Trim any jump stitches or loose threads",
'Click "Delete Pattern" to start a new project',
],
};
default:
return null;
}
}
function getCurrentStep(
machineStatus: MachineStatus,
isConnected: boolean,
hasPattern: boolean,
patternUploaded: boolean,
): number {
if (!isConnected) return 1;
// Check if machine needs homing (Initial state)
if (machineStatus === MachineStatus.Initial) return 2;
if (!hasPattern) return 3;
if (!patternUploaded) return 4;
// After upload, determine step based on machine status
switch (machineStatus) {
case MachineStatus.IDLE:
case MachineStatus.MASK_TRACE_LOCK_WAIT:
case MachineStatus.MASK_TRACING:
return 5;
case MachineStatus.MASK_TRACE_COMPLETE:
case MachineStatus.SEWING_WAIT:
return 6;
case MachineStatus.SEWING:
case MachineStatus.COLOR_CHANGE_WAIT:
case MachineStatus.PAUSE:
case MachineStatus.STOP:
case MachineStatus.SEWING_INTERRUPTION:
return 7;
case MachineStatus.SEWING_COMPLETE:
return 8;
default:
return 5;
}
}
export function WorkflowStepper() {
// Machine store
const { machineStatus, isConnected } = useMachineStore(
useShallow((state) => ({
machineStatus: state.machineStatus,
isConnected: state.isConnected,
})),
);
// Pattern store
const { pesData } = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
})),
);
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
const hasPattern = pesData !== null;
const currentStep = getCurrentStep(
machineStatus,
isConnected,
hasPattern,
patternUploaded,
);
const [showPopover, setShowPopover] = useState(false);
const [popoverStep, setPopoverStep] = useState<number | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
// Close popover when clicking outside (exclude step circles)
useClickOutside<HTMLDivElement>(popoverRef, () => setShowPopover(false), {
enabled: showPopover,
excludeRefs: [stepRefs],
});
const handleStepClick = (stepId: number) => {
// Only allow clicking on current step or earlier completed steps
if (stepId <= currentStep) {
if (showPopover && popoverStep === stepId) {
setShowPopover(false);
setPopoverStep(null);
} else {
setPopoverStep(stepId);
setShowPopover(true);
}
}
};
return (
<div
className="relative max-w-5xl mx-auto mt-2 lg:mt-4"
role="navigation"
aria-label="Workflow progress"
>
{/* Progress bar background */}
<div
className="absolute top-4 lg:top-5 left-0 right-0 h-0.5 lg:h-1 bg-primary-400/20 dark:bg-primary-600/20 rounded-full"
style={{ left: "16px", right: "16px" }}
/>
{/* Progress bar fill */}
<div
className="absolute top-4 lg:top-5 left-0 h-0.5 lg:h-1 bg-gradient-to-r from-success-500 to-primary-500 dark:from-success-600 dark:to-primary-600 transition-all duration-500 rounded-full"
style={{
left: "16px",
width: `calc(${((currentStep - 1) / (steps.length - 1)) * 100}% - 16px)`,
}}
role="progressbar"
aria-valuenow={currentStep}
aria-valuemin={1}
aria-valuemax={steps.length}
aria-label={`Step ${currentStep} of ${steps.length}`}
/>
{/* Steps */}
<div className="flex justify-between relative">
{steps.map((step) => {
const isComplete = step.id < currentStep;
const isCurrent = step.id === currentStep;
const isUpcoming = step.id > currentStep;
return (
<div
key={step.id}
className="flex flex-col items-center"
style={{ flex: 1 }}
role="listitem"
aria-current={isCurrent ? "step" : undefined}
>
{/* Step circle */}
<div
ref={(el) => {
stepRefs.current[step.id] = el;
}}
onClick={() => handleStepClick(step.id)}
className={`
w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-300 border-2 shadow-md
${step.id <= currentStep ? "cursor-pointer hover:scale-110" : "cursor-not-allowed"}
${isComplete ? "bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30" : ""}
${isCurrent ? "bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900" : ""}
${isUpcoming ? "bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70" : ""}
${showPopover && popoverStep === step.id ? "ring-4 ring-white dark:ring-gray-800" : ""}
`}
aria-label={`${step.label}: ${isComplete ? "completed" : isCurrent ? "current" : "upcoming"}. Click for details.`}
role="button"
tabIndex={step.id <= currentStep ? 0 : -1}
>
{isComplete ? (
<CheckCircleIcon
className="w-5 h-5 lg:w-6 lg:h-6"
aria-hidden="true"
/>
) : (
step.id
)}
</div>
{/* Step label */}
<div className="mt-1 lg:mt-2 text-center">
<div
className={`text-xs font-semibold leading-tight ${
isCurrent
? "text-white"
: isComplete
? "text-success-200 dark:text-success-300"
: "text-primary-300/70 dark:text-primary-400/70"
}`}
>
{step.label}
</div>
</div>
</div>
);
})}
</div>
{/* Popover */}
{showPopover && popoverStep !== null && (
<div
ref={popoverRef}
className="absolute top-full mt-4 left-1/2 transform -translate-x-1/2 w-full max-w-xl z-50 animate-fadeIn"
role="dialog"
aria-label="Step guidance"
>
{(() => {
const content = getGuideContent(popoverStep, machineStatus);
if (!content) return null;
const colorClasses = {
info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
success:
"bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500",
warning:
"bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500",
error:
"bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
progress:
"bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
};
const iconColorClasses = {
info: "text-info-600 dark:text-info-400",
success: "text-success-600 dark:text-success-400",
warning: "text-warning-600 dark:text-warning-400",
error: "text-danger-600 dark:text-danger-400",
progress: "text-info-600 dark:text-info-400",
};
const textColorClasses = {
info: "text-info-900 dark:text-info-200",
success: "text-success-900 dark:text-success-200",
warning: "text-warning-900 dark:text-warning-200",
error: "text-danger-900 dark:text-danger-200",
progress: "text-info-900 dark:text-info-200",
};
const descColorClasses = {
info: "text-info-800 dark:text-info-300",
success: "text-success-800 dark:text-success-300",
warning: "text-warning-800 dark:text-warning-300",
error: "text-danger-800 dark:text-danger-300",
progress: "text-info-800 dark:text-info-300",
};
const listColorClasses = {
info: "text-blue-700 dark:text-blue-300",
success: "text-green-700 dark:text-green-300",
warning: "text-yellow-700 dark:text-yellow-300",
error: "text-red-700 dark:text-red-300",
progress: "text-cyan-700 dark:text-cyan-300",
};
const Icon =
content.type === "warning"
? ExclamationTriangleIcon
: InformationCircleIcon;
return (
<div
className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
>
<div className="flex items-start gap-3">
<Icon
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
/>
<div className="flex-1">
<h3
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
>
{content.title}
</h3>
<p
className={`text-sm ${descColorClasses[content.type]} mb-3`}
>
{content.description}
</p>
{content.items && content.items.length > 0 && (
<ul
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
>
{content.items.map((item, index) => (
<li
key={index}
className="pl-2"
dangerouslySetInnerHTML={{
__html: item.replace(
/\*\*(.*?)\*\*/g,
"<strong>$1</strong>",
),
}}
/>
))}
</ul>
)}
</div>
</div>
</div>
);
})()}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,54 @@
/**
* StepCircle Component
*
* Renders a circular step indicator with number or checkmark icon
*/
import { forwardRef } from "react";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
export interface StepCircleProps {
stepId: number;
label: string;
isComplete: boolean;
isCurrent: boolean;
isUpcoming: boolean;
showPopover: boolean;
onClick: () => void;
}
export const StepCircle = forwardRef<HTMLDivElement, StepCircleProps>(
(
{ stepId, label, isComplete, isCurrent, isUpcoming, showPopover, onClick },
ref,
) => {
return (
<div
ref={ref}
onClick={onClick}
className={`
w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-300 border-2 shadow-md
${stepId <= (isCurrent ? stepId : isComplete ? stepId : stepId - 1) ? "cursor-pointer hover:scale-110" : "cursor-not-allowed"}
${isComplete ? "bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30" : ""}
${isCurrent ? "bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900" : ""}
${isUpcoming ? "bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70" : ""}
${showPopover ? "ring-4 ring-white dark:ring-gray-800" : ""}
`}
aria-label={`${label}: ${isComplete ? "completed" : isCurrent ? "current" : "upcoming"}. Click for details.`}
role="button"
tabIndex={isComplete || isCurrent ? 0 : -1}
>
{isComplete ? (
<CheckCircleIcon
className="w-5 h-5 lg:w-6 lg:h-6"
aria-hidden="true"
/>
) : (
stepId
)}
</div>
);
},
);
StepCircle.displayName = "StepCircle";

View file

@ -0,0 +1,29 @@
/**
* StepLabel Component
*
* Renders the text label below each step circle
*/
export interface StepLabelProps {
label: string;
isCurrent: boolean;
isComplete: boolean;
}
export function StepLabel({ label, isCurrent, isComplete }: StepLabelProps) {
return (
<div className="mt-1 lg:mt-2 text-center">
<div
className={`text-xs font-semibold leading-tight ${
isCurrent
? "text-white"
: isComplete
? "text-success-200 dark:text-success-300"
: "text-primary-300/70 dark:text-primary-400/70"
}`}
>
{label}
</div>
</div>
);
}

View file

@ -0,0 +1,123 @@
/**
* StepPopover Component
*
* Renders the guidance popover with dynamic content based on step and machine status
*/
import { forwardRef } from "react";
import {
InformationCircleIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/solid";
import { MachineStatus } from "../../types/machine";
import { getGuideContent } from "../../utils/workflowGuideContent";
export interface StepPopoverProps {
stepId: number;
machineStatus: MachineStatus;
}
export const StepPopover = forwardRef<HTMLDivElement, StepPopoverProps>(
({ stepId, machineStatus }, ref) => {
const content = getGuideContent(stepId, machineStatus);
if (!content) return null;
const colorClasses = {
info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
success:
"bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500",
warning:
"bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500",
error:
"bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
progress:
"bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
};
const iconColorClasses = {
info: "text-info-600 dark:text-info-400",
success: "text-success-600 dark:text-success-400",
warning: "text-warning-600 dark:text-warning-400",
error: "text-danger-600 dark:text-danger-400",
progress: "text-info-600 dark:text-info-400",
};
const textColorClasses = {
info: "text-info-900 dark:text-info-200",
success: "text-success-900 dark:text-success-200",
warning: "text-warning-900 dark:text-warning-200",
error: "text-danger-900 dark:text-danger-200",
progress: "text-info-900 dark:text-info-200",
};
const descColorClasses = {
info: "text-info-800 dark:text-info-300",
success: "text-success-800 dark:text-success-300",
warning: "text-warning-800 dark:text-warning-300",
error: "text-danger-800 dark:text-danger-300",
progress: "text-info-800 dark:text-info-300",
};
const listColorClasses = {
info: "text-blue-700 dark:text-blue-300",
success: "text-green-700 dark:text-green-300",
warning: "text-yellow-700 dark:text-yellow-300",
error: "text-red-700 dark:text-red-300",
progress: "text-cyan-700 dark:text-cyan-300",
};
const Icon =
content.type === "warning"
? ExclamationTriangleIcon
: InformationCircleIcon;
return (
<div
ref={ref}
className="absolute top-full mt-4 left-1/2 transform -translate-x-1/2 w-full max-w-xl z-50 animate-fadeIn"
role="dialog"
aria-label="Step guidance"
>
<div
className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
>
<div className="flex items-start gap-3">
<Icon
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
/>
<div className="flex-1">
<h3
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
>
{content.title}
</h3>
<p className={`text-sm ${descColorClasses[content.type]} mb-3`}>
{content.description}
</p>
{content.items && content.items.length > 0 && (
<ul
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
>
{content.items.map((item, index) => (
<li
key={index}
className="pl-2"
dangerouslySetInnerHTML={{
__html: item.replace(
/\*\*(.*?)\*\*/g,
"<strong>$1</strong>",
),
}}
/>
))}
</ul>
)}
</div>
</div>
</div>
</div>
);
},
);
StepPopover.displayName = "StepPopover";

View file

@ -0,0 +1,141 @@
/**
* WorkflowStepper Component
*
* Displays the 8-step embroidery workflow with progress tracking and contextual guidance
*/
import { useState, useRef } from "react";
import { useClickOutside } from "@/hooks";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore, usePatternUploaded } from "../../stores/useMachineStore";
import { usePatternStore } from "../../stores/usePatternStore";
import { WORKFLOW_STEPS } from "../../constants/workflowSteps";
import { getCurrentStep } from "../../utils/workflowStepCalculation";
import { StepCircle } from "./StepCircle";
import { StepLabel } from "./StepLabel";
import { StepPopover } from "./StepPopover";
export function WorkflowStepper() {
// Machine store
const { machineStatus, isConnected } = useMachineStore(
useShallow((state) => ({
machineStatus: state.machineStatus,
isConnected: state.isConnected,
})),
);
// Pattern store
const { pesData } = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
})),
);
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
const hasPattern = pesData !== null;
const currentStep = getCurrentStep(
machineStatus,
isConnected,
hasPattern,
patternUploaded,
);
const [showPopover, setShowPopover] = useState(false);
const [popoverStep, setPopoverStep] = useState<number | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
// Close popover when clicking outside (exclude step circles)
useClickOutside<HTMLDivElement>(popoverRef, () => setShowPopover(false), {
enabled: showPopover,
excludeRefs: [stepRefs],
});
const handleStepClick = (stepId: number) => {
// Only allow clicking on current step or earlier completed steps
if (stepId <= currentStep) {
if (showPopover && popoverStep === stepId) {
setShowPopover(false);
setPopoverStep(null);
} else {
setPopoverStep(stepId);
setShowPopover(true);
}
}
};
return (
<div
className="relative max-w-5xl mx-auto mt-2 lg:mt-4"
role="navigation"
aria-label="Workflow progress"
>
{/* Progress bar background */}
<div
className="absolute top-4 lg:top-5 left-0 right-0 h-0.5 lg:h-1 bg-primary-400/20 dark:bg-primary-600/20 rounded-full"
style={{ left: "16px", right: "16px" }}
/>
{/* Progress bar fill */}
<div
className="absolute top-4 lg:top-5 left-0 h-0.5 lg:h-1 bg-gradient-to-r from-success-500 to-primary-500 dark:from-success-600 dark:to-primary-600 transition-all duration-500 rounded-full"
style={{
left: "16px",
width: `calc(${((currentStep - 1) / (WORKFLOW_STEPS.length - 1)) * 100}% - 16px)`,
}}
role="progressbar"
aria-valuenow={currentStep}
aria-valuemin={1}
aria-valuemax={WORKFLOW_STEPS.length}
aria-label={`Step ${currentStep} of ${WORKFLOW_STEPS.length}`}
/>
{/* Steps */}
<div className="flex justify-between relative">
{WORKFLOW_STEPS.map((step) => {
const isComplete = step.id < currentStep;
const isCurrent = step.id === currentStep;
const isUpcoming = step.id > currentStep;
return (
<div
key={step.id}
className="flex flex-col items-center"
style={{ flex: 1 }}
role="listitem"
aria-current={isCurrent ? "step" : undefined}
>
<StepCircle
ref={(el: HTMLDivElement | null) => {
stepRefs.current[step.id] = el;
}}
stepId={step.id}
label={step.label}
isComplete={isComplete}
isCurrent={isCurrent}
isUpcoming={isUpcoming}
showPopover={showPopover && popoverStep === step.id}
onClick={() => handleStepClick(step.id)}
/>
<StepLabel
label={step.label}
isCurrent={isCurrent}
isComplete={isComplete}
/>
</div>
);
})}
</div>
{/* Popover */}
{showPopover && popoverStep !== null && (
<StepPopover
ref={popoverRef}
stepId={popoverStep}
machineStatus={machineStatus}
/>
)}
</div>
);
}

View file

@ -0,0 +1,5 @@
/**
* WorkflowStepper component barrel export
*/
export { WorkflowStepper } from "./WorkflowStepper";

View file

@ -0,0 +1,20 @@
/**
* Workflow step definitions for the embroidery process
*/
export interface WorkflowStep {
id: number;
label: string;
description: string;
}
export const WORKFLOW_STEPS: readonly WorkflowStep[] = [
{ id: 1, label: "Connect", description: "Connect to machine" },
{ id: 2, label: "Home Machine", description: "Initialize hoop position" },
{ id: 3, label: "Load Pattern", description: "Choose PES file" },
{ id: 4, label: "Upload", description: "Upload to machine" },
{ id: 5, label: "Mask Trace", description: "Trace pattern area" },
{ id: 6, label: "Start Sewing", description: "Begin embroidery" },
{ id: 7, label: "Monitor", description: "Watch progress" },
{ id: 8, label: "Complete", description: "Finish and remove" },
] as const;

View file

@ -0,0 +1,195 @@
/**
* Workflow step guide content
*
* Provides contextual guidance for each workflow step based on machine state
*/
import { MachineStatus } from "../types/machine";
export interface GuideContent {
type: "info" | "warning" | "success" | "error" | "progress";
title: string;
description: string;
items: string[];
}
/**
* Get guide content for a specific workflow step
*
* @param stepId - The workflow step ID (1-8)
* @param machineStatus - Current machine status for dynamic content
* @returns Guide content with type, title, description, and items
*/
export function getGuideContent(
stepId: number,
machineStatus: MachineStatus,
): GuideContent | null {
switch (stepId) {
case 1:
return {
type: "info",
title: "Step 1: Connect to Machine",
description:
"To get started, connect to your Brother embroidery machine via Bluetooth.",
items: [
"Make sure your machine is powered on",
"Enable Bluetooth on your machine",
'Click the "Connect to Machine" button below',
],
};
case 2:
return {
type: "info",
title: "Step 2: Home Machine",
description:
"The hoop needs to be removed and an initial homing procedure must be performed.",
items: [
"Remove the embroidery hoop from the machine completely",
"Press the Accept button on the machine",
"Wait for the machine to complete its initialization (homing)",
"Once initialization is complete, reattach the hoop",
"The machine should now recognize the hoop correctly",
],
};
case 3:
return {
type: "info",
title: "Step 3: Load Your Pattern",
description:
"Choose a PES embroidery file from your computer to preview and upload.",
items: [
'Click "Choose PES File" in the Pattern File section',
"Select your embroidery design (.pes file)",
"Review the pattern preview on the right",
"You can drag the pattern to adjust its position",
],
};
case 4:
return {
type: "info",
title: "Step 4: Upload Pattern to Machine",
description:
"Send your pattern to the embroidery machine to prepare for sewing.",
items: [
"Review the pattern preview to ensure it's positioned correctly",
"Check the pattern size matches your hoop",
'Click "Upload to Machine" when ready',
"Wait for the upload to complete (this may take a minute)",
],
};
case 5:
// Check machine status for substates
if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) {
return {
type: "warning",
title: "Machine Action Required",
description: "The machine is ready to trace the pattern outline.",
items: [
"Press the button on your machine to confirm and start the mask trace",
"Ensure the hoop is properly attached",
"Make sure the needle area is clear",
],
};
}
if (machineStatus === MachineStatus.MASK_TRACING) {
return {
type: "progress",
title: "Mask Trace In Progress",
description:
"The machine is tracing the pattern boundary. Please wait...",
items: [
"Watch the machine trace the outline",
"Verify the pattern fits within your hoop",
"Do not interrupt the machine",
],
};
}
return {
type: "info",
title: "Step 5: Start Mask Trace",
description:
"The mask trace helps the machine understand the pattern boundaries.",
items: [
'Click "Start Mask Trace" button in the Sewing Progress section',
"The machine will trace the pattern outline",
"This ensures the hoop is positioned correctly",
],
};
case 6:
return {
type: "success",
title: "Step 6: Ready to Sew!",
description: "The machine is ready to begin embroidering your pattern.",
items: [
"Verify your thread colors are correct",
"Ensure the fabric is properly hooped",
'Click "Start Sewing" when ready',
],
};
case 7:
// Check for substates
if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) {
return {
type: "warning",
title: "Thread Change Required",
description:
"The machine needs a different thread color to continue.",
items: [
"Check the color blocks section to see which thread is needed",
"Change to the correct thread color",
"Press the button on your machine to resume sewing",
],
};
}
if (
machineStatus === MachineStatus.PAUSE ||
machineStatus === MachineStatus.STOP ||
machineStatus === MachineStatus.SEWING_INTERRUPTION
) {
return {
type: "warning",
title: "Sewing Paused",
description: "The embroidery has been paused or interrupted.",
items: [
"Check if everything is okay with the machine",
'Click "Resume Sewing" when ready to continue',
"The machine will pick up where it left off",
],
};
}
return {
type: "progress",
title: "Step 7: Sewing In Progress",
description:
"Your embroidery is being stitched. Monitor the progress below.",
items: [
"Watch the progress bar and current stitch count",
"The machine will pause when a color change is needed",
"Do not leave the machine unattended",
],
};
case 8:
return {
type: "success",
title: "Step 8: Embroidery Complete!",
description: "Your embroidery is finished. Great work!",
items: [
"Remove the hoop from the machine",
"Press the Accept button on the machine",
"Carefully remove your finished embroidery",
"Trim any jump stitches or loose threads",
'Click "Delete Pattern" to start a new project',
],
};
default:
return null;
}
}

View file

@ -0,0 +1,56 @@
/**
* Workflow step calculation utilities
*
* Determines the current workflow step based on machine state and pattern status
*/
import { MachineStatus } from "../types/machine";
/**
* Calculate the current workflow step based on machine state
*
* @param machineStatus - Current machine status
* @param isConnected - Whether machine is connected
* @param hasPattern - Whether a pattern is loaded
* @param patternUploaded - Whether pattern has been uploaded to machine
* @returns Current step number (1-8)
*/
export function getCurrentStep(
machineStatus: MachineStatus,
isConnected: boolean,
hasPattern: boolean,
patternUploaded: boolean,
): number {
if (!isConnected) return 1;
// Check if machine needs homing (Initial state)
if (machineStatus === MachineStatus.Initial) return 2;
if (!hasPattern) return 3;
if (!patternUploaded) return 4;
// After upload, determine step based on machine status
switch (machineStatus) {
case MachineStatus.IDLE:
case MachineStatus.MASK_TRACE_LOCK_WAIT:
case MachineStatus.MASK_TRACING:
return 5;
case MachineStatus.MASK_TRACE_COMPLETE:
case MachineStatus.SEWING_WAIT:
return 6;
case MachineStatus.SEWING:
case MachineStatus.COLOR_CHANGE_WAIT:
case MachineStatus.PAUSE:
case MachineStatus.STOP:
case MachineStatus.SEWING_INTERRUPTION:
return 7;
case MachineStatus.SEWING_COMPLETE:
return 8;
default:
return 5;
}
}