Move error display to header with clickable popover

- Replace content-pushing error banners with header status indicator
- Add categorized error labels (Python Error, Connection Error, etc.)
- Show detailed error info with solutions in 600px popover on click
- Fix layout shift by always rendering error button (invisible when no error)
- Clear machineError state on disconnect
- Fix TypeScript error in WorkflowStepper ref callback

🤖 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-11 12:31:57 +01:00
parent bf3e397ddb
commit efc712995b
4 changed files with 467 additions and 419 deletions

View file

@ -1,17 +1,16 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useBrotherMachine } from './hooks/useBrotherMachine';
import { FileUpload } from './components/FileUpload';
import { PatternCanvas } from './components/PatternCanvas';
import { ProgressMonitor } from './components/ProgressMonitor';
import { WorkflowStepper } from './components/WorkflowStepper';
import { NextStepGuide } from './components/NextStepGuide';
import { PatternSummaryCard } from './components/PatternSummaryCard';
import { BluetoothDevicePicker } from './components/BluetoothDevicePicker';
import type { PesPatternData } from './utils/pystitchConverter';
import { pyodideLoader } from './utils/pyodideLoader';
import { hasError } from './utils/errorCodeHelpers';
import { hasError, getErrorDetails } from './utils/errorCodeHelpers';
import { canDeletePattern, getStateVisualInfo } from './utils/machineStateHelpers';
import { CheckCircleIcon, BoltIcon, PauseCircleIcon, ExclamationTriangleIcon, ArrowPathIcon, XMarkIcon } from '@heroicons/react/24/solid';
import { CheckCircleIcon, BoltIcon, PauseCircleIcon, ExclamationTriangleIcon, ArrowPathIcon, XMarkIcon, InformationCircleIcon } from '@heroicons/react/24/solid';
import './App.css';
function App() {
@ -22,6 +21,9 @@ function App() {
const [patternOffset, setPatternOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [patternUploaded, setPatternUploaded] = useState(false);
const [currentFileName, setCurrentFileName] = useState<string>(''); // Track current pattern filename
const [showErrorPopover, setShowErrorPopover] = useState(false);
const errorPopoverRef = useRef<HTMLDivElement>(null);
const errorButtonRef = useRef<HTMLButtonElement>(null);
// Initialize Pyodide on mount
useEffect(() => {
@ -37,6 +39,25 @@ function App() {
});
}, []);
// Close error popover when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
errorPopoverRef.current &&
!errorPopoverRef.current.contains(event.target as Node) &&
errorButtonRef.current &&
!errorButtonRef.current.contains(event.target as Node)
) {
setShowErrorPopover(false);
}
};
if (showErrorPopover) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showErrorPopover]);
// Auto-load cached pattern when available
const resumedPattern = machine.resumedPattern;
const resumeFileName = machine.resumeFileName;
@ -142,7 +163,7 @@ function App() {
<ArrowPathIcon className="w-3.5 h-3.5 text-blue-200 animate-spin" title="Auto-refreshing status" />
)}
</div>
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
{machine.isConnected ? (
<>
<button
@ -162,6 +183,122 @@ function App() {
) : (
<p className="text-xs text-blue-200">Not Connected</p>
)}
{/* Error indicator - always render to prevent layout shift */}
<div className="relative">
<button
ref={errorButtonRef}
onClick={() => setShowErrorPopover(!showErrorPopover)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-red-500/90 hover:bg-red-600 text-white border border-red-400 transition-all flex-shrink-0 ${
(machine.error || pyodideError)
? 'cursor-pointer animate-pulse hover:animate-none'
: 'invisible pointer-events-none'
}`}
title="Click to view error details"
aria-label="View error details"
disabled={!(machine.error || pyodideError)}
>
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{(() => {
if (pyodideError) return 'Python Error';
if (machine.isPairingError) return 'Pairing Required';
const errorMsg = machine.error || '';
// Categorize by error message content
if (errorMsg.toLowerCase().includes('bluetooth') || errorMsg.toLowerCase().includes('connection')) {
return 'Connection Error';
}
if (errorMsg.toLowerCase().includes('upload')) {
return 'Upload Error';
}
if (errorMsg.toLowerCase().includes('pattern')) {
return 'Pattern Error';
}
if (machine.machineError !== undefined) {
return `Machine Error`;
}
// Default fallback
return 'Error';
})()}
</span>
</button>
{/* Error popover */}
{showErrorPopover && (machine.error || pyodideError) && (
<div
ref={errorPopoverRef}
className="absolute top-full mt-2 left-0 w-[600px] z-50 animate-fadeIn"
role="dialog"
aria-label="Error details"
>
{(() => {
const errorDetails = getErrorDetails(machine.machineError);
const isPairingError = machine.isPairingError;
const errorMsg = pyodideError || machine.error || '';
const isInfo = isPairingError || errorDetails?.isInformational;
const bgColor = isInfo
? 'bg-blue-50 dark:bg-blue-900/95 border-blue-600 dark:border-blue-500'
: 'bg-red-50 dark:bg-red-900/95 border-red-600 dark:border-red-500';
const iconColor = isInfo
? 'text-blue-600 dark:text-blue-400'
: 'text-red-600 dark:text-red-400';
const textColor = isInfo
? 'text-blue-900 dark:text-blue-200'
: 'text-red-900 dark:text-red-200';
const descColor = isInfo
? 'text-blue-800 dark:text-blue-300'
: 'text-red-800 dark:text-red-300';
const listColor = isInfo
? 'text-blue-700 dark:text-blue-300'
: 'text-red-700 dark:text-red-300';
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
const title = errorDetails?.title || (isPairingError ? 'Pairing Required' : 'Error');
return (
<div className={`${bgColor} 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 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1">
<h3 className={`text-base font-semibold ${textColor} mb-2`}>
{title}
</h3>
<p className={`text-sm ${descColor} mb-3`}>
{errorDetails?.description || errorMsg}
</p>
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
<>
<h4 className={`text-sm font-semibold ${textColor} mb-2`}>
{isInfo ? 'Steps:' : 'How to Fix:'}
</h4>
<ol className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}>
{errorDetails.solutions.map((solution, index) => (
<li key={index} className="pl-2">{solution}</li>
))}
</ol>
</>
)}
{machine.machineError !== undefined && !errorDetails?.isInformational && (
<p className={`text-xs ${descColor} mt-3 font-mono`}>
Error Code: 0x{machine.machineError.toString(16).toUpperCase().padStart(2, '0')}
</p>
)}
</div>
</div>
</div>
);
})()}
</div>
)}
</div>
</div>
</div>
</div>
@ -173,58 +310,15 @@ function App() {
isConnected={machine.isConnected}
hasPattern={pesData !== null}
patternUploaded={patternUploaded}
hasError={hasError(machine.machineError)}
errorMessage={machine.error || undefined}
errorCode={machine.machineError}
/>
</div>
</div>
</header>
<div className="flex-1 p-4 sm:p-5 lg:p-6 w-full overflow-y-auto lg:overflow-hidden flex flex-col">
{/* Global errors */}
{machine.error && (
<div className={`px-6 py-4 rounded-lg border-l-4 mb-6 shadow-md hover:shadow-lg transition-shadow animate-fadeIn ${
machine.isPairingError
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-900 dark:text-blue-200 border-blue-600 dark:border-blue-500'
: 'bg-red-50 dark:bg-red-900/20 text-red-900 dark:text-red-200 border-red-600 dark:border-red-500'
}`}>
<div className="flex items-center gap-3">
<svg className={`w-5 h-5 flex-shrink-0 ${machine.isPairingError ? 'text-blue-600 dark:text-blue-400' : 'text-red-600 dark:text-red-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
{machine.isPairingError ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
)}
</svg>
<div className="flex-1">
<div className="font-semibold mb-1">{machine.isPairingError ? 'Pairing Required' : 'Error'}</div>
<div className="text-sm">{machine.error}</div>
</div>
</div>
</div>
)}
{pyodideError && (
<div className="bg-red-50 dark:bg-red-900/20 text-red-900 dark:text-red-200 px-6 py-4 rounded-lg border-l-4 border-red-600 dark:border-red-500 mb-6 shadow-md hover:shadow-lg transition-shadow animate-fadeIn">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<strong className="font-semibold">Python Error:</strong> {pyodideError}
</div>
</div>
</div>
)}
{!pyodideReady && !pyodideError && (
<div className="bg-blue-50 dark:bg-blue-900/20 text-blue-900 dark:text-blue-200 px-6 py-4 rounded-lg border-l-4 border-blue-600 dark:border-blue-500 mb-6 shadow-md animate-fadeIn">
<div className="flex items-center gap-3">
<svg className="w-5 h-5 animate-spin text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span className="font-medium">Initializing Python environment...</span>
</div>
</div>
)}
<div className="flex-1 grid grid-cols-1 lg:grid-cols-[400px_1fr] gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
{/* Left Column - Controls */}
<div className="flex flex-col gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
@ -362,17 +456,6 @@ function App() {
{/* Bluetooth Device Picker (Electron only) */}
<BluetoothDevicePicker />
</div>
{/* Next Step Guide - Fixed floating overlay */}
<NextStepGuide
machineStatus={machine.machineStatus}
isConnected={machine.isConnected}
hasPattern={pesData !== null}
patternUploaded={patternUploaded}
hasError={hasError(machine.machineError)}
errorMessage={machine.error || undefined}
errorCode={machine.machineError}
/>
</div>
);
}

View file

@ -1,354 +0,0 @@
import { useState, useEffect } from 'react';
import { InformationCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/react/24/solid';
import { MachineStatus } from '../types/machine';
import { getErrorDetails } from '../utils/errorCodeHelpers';
interface NextStepGuideProps {
machineStatus: MachineStatus;
isConnected: boolean;
hasPattern: boolean;
patternUploaded: boolean;
hasError: boolean;
errorMessage?: string;
errorCode?: number;
}
export function NextStepGuide({
machineStatus,
isConnected,
hasPattern,
patternUploaded,
hasError,
errorMessage,
errorCode
}: NextStepGuideProps) {
const [isExpanded, setIsExpanded] = useState(true);
// Expand when state changes
useEffect(() => {
setIsExpanded(true);
}, [machineStatus, isConnected, hasPattern, patternUploaded, hasError]);
// Render guide content based on state
const renderContent = () => {
// Don't show if there's an error - show detailed error guidance instead
if (hasError) {
const errorDetails = getErrorDetails(errorCode);
// Check if this is informational (like initialization steps) vs a real error
if (errorDetails?.isInformational) {
return (
<div className="bg-blue-50 dark:bg-blue-900/95 border-l-4 border-blue-600 dark:border-blue-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-blue-900 dark:text-blue-200 mb-2">
{errorDetails.title}
</h3>
<p className="text-sm text-blue-800 dark:text-blue-300 mb-3">
{errorDetails.description}
</p>
{errorDetails.solutions && errorDetails.solutions.length > 0 && (
<>
<h4 className="text-sm font-semibold text-blue-900 dark:text-blue-200 mb-2">Steps:</h4>
<ol className="list-decimal list-inside text-sm text-blue-700 dark:text-blue-300 space-y-1.5">
{errorDetails.solutions.map((solution, index) => (
<li key={index} className="pl-2">{solution}</li>
))}
</ol>
</>
)}
</div>
</div>
</div>
);
}
// Regular error display for actual errors
return (
<div className="bg-red-50 dark:bg-red-900/95 border-l-4 border-red-600 dark:border-red-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-red-900 dark:text-red-200 mb-2">
{errorDetails?.title || 'Error Occurred'}
</h3>
<p className="text-sm text-red-800 dark:text-red-300 mb-3">
{errorDetails?.description || errorMessage || 'An error occurred. Please check the machine and try again.'}
</p>
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
<>
<h4 className="text-sm font-semibold text-red-900 dark:text-red-200 mb-2">How to Fix:</h4>
<ol className="list-decimal list-inside text-sm text-red-700 dark:text-red-300 space-y-1.5">
{errorDetails.solutions.map((solution, index) => (
<li key={index} className="pl-2">{solution}</li>
))}
</ol>
</>
)}
{errorCode !== undefined && (
<p className="text-xs text-red-600 dark:text-red-400 mt-3 font-mono">
Error Code: 0x{errorCode.toString(16).toUpperCase().padStart(2, '0')}
</p>
)}
</div>
</div>
</div>
);
}
// Determine what to show based on current state
if (!isConnected) {
return (
<div className="bg-blue-50 dark:bg-blue-900/95 border-l-4 border-blue-600 dark:border-blue-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-blue-900 dark:text-blue-200 mb-2">Step 1: Connect to Machine</h3>
<p className="text-sm text-blue-800 dark:text-blue-300 mb-3">To get started, connect to your Brother embroidery machine via Bluetooth.</p>
<ul className="list-disc list-inside text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li>Make sure your machine is powered on</li>
<li>Enable Bluetooth on your machine</li>
<li>Click the "Connect to Machine" button below</li>
</ul>
</div>
</div>
</div>
);
}
if (!hasPattern) {
return (
<div className="bg-blue-50 dark:bg-blue-900/95 border-l-4 border-blue-600 dark:border-blue-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-blue-900 dark:text-blue-200 mb-2">Step 2: Load Your Pattern</h3>
<p className="text-sm text-blue-800 dark:text-blue-300 mb-3">Choose a PES embroidery file from your computer to preview and upload.</p>
<ul className="list-disc list-inside text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li>Click "Choose PES File" in the Pattern File section</li>
<li>Select your embroidery design (.pes file)</li>
<li>Review the pattern preview on the right</li>
<li>You can drag the pattern to adjust its position</li>
</ul>
</div>
</div>
</div>
);
}
if (!patternUploaded) {
return (
<div className="bg-blue-50 dark:bg-blue-900/95 border-l-4 border-blue-600 dark:border-blue-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-blue-900 dark:text-blue-200 mb-2">Step 3: Upload Pattern to Machine</h3>
<p className="text-sm text-blue-800 dark:text-blue-300 mb-3">Send your pattern to the embroidery machine to prepare for sewing.</p>
<ul className="list-disc list-inside text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li>Review the pattern preview to ensure it's positioned correctly</li>
<li>Check the pattern size matches your hoop</li>
<li>Click "Upload to Machine" when ready</li>
<li>Wait for the upload to complete (this may take a minute)</li>
</ul>
</div>
</div>
</div>
);
}
// Pattern is uploaded, guide based on machine status
switch (machineStatus) {
case MachineStatus.IDLE:
return (
<div className="bg-blue-50 dark:bg-blue-900/95 border-l-4 border-blue-600 dark:border-blue-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-blue-900 dark:text-blue-200 mb-2">Step 4: Start Mask Trace</h3>
<p className="text-sm text-blue-800 dark:text-blue-300 mb-3">The mask trace helps the machine understand the pattern boundaries.</p>
<ul className="list-disc list-inside text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li>Click "Start Mask Trace" button in the Sewing Progress section</li>
<li>The machine will trace the pattern outline</li>
<li>This ensures the hoop is positioned correctly</li>
</ul>
</div>
</div>
</div>
);
case MachineStatus.MASK_TRACE_LOCK_WAIT:
return (
<div className="bg-yellow-50 dark:bg-yellow-900/95 border-l-4 border-yellow-600 dark:border-yellow-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-yellow-900 dark:text-yellow-200 mb-2">Machine Action Required</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-3">The machine is ready to trace the pattern outline.</p>
<ul className="list-disc list-inside text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
<li><strong>Press the button on your machine</strong> to confirm and start the mask trace</li>
<li>Ensure the hoop is properly attached</li>
<li>Make sure the needle area is clear</li>
</ul>
</div>
</div>
</div>
);
case MachineStatus.MASK_TRACING:
return (
<div className="bg-cyan-50 dark:bg-cyan-900/95 border-l-4 border-cyan-600 dark:border-cyan-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-6 h-6 text-cyan-600 dark:text-cyan-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-cyan-900 dark:text-cyan-200 mb-2">Mask Trace In Progress</h3>
<p className="text-sm text-cyan-800 dark:text-cyan-300 mb-3">The machine is tracing the pattern boundary. Please wait...</p>
<ul className="list-disc list-inside text-sm text-cyan-700 dark:text-cyan-300 space-y-1">
<li>Watch the machine trace the outline</li>
<li>Verify the pattern fits within your hoop</li>
<li>Do not interrupt the machine</li>
</ul>
</div>
</div>
</div>
);
case MachineStatus.MASK_TRACE_COMPLETE:
case MachineStatus.SEWING_WAIT:
return (
<div className="bg-green-50 dark:bg-green-900/95 border-l-4 border-green-600 dark:border-green-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-6 h-6 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-green-900 dark:text-green-200 mb-2">Step 5: Ready to Sew!</h3>
<p className="text-sm text-green-800 dark:text-green-300 mb-3">The machine is ready to begin embroidering your pattern.</p>
<ul className="list-disc list-inside text-sm text-green-700 dark:text-green-300 space-y-1">
<li>Verify your thread colors are correct</li>
<li>Ensure the fabric is properly hooped</li>
<li>Click "Start Sewing" when ready</li>
</ul>
</div>
</div>
</div>
);
case MachineStatus.SEWING:
return (
<div className="bg-cyan-50 dark:bg-cyan-900/95 border-l-4 border-cyan-600 dark:border-cyan-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-6 h-6 text-cyan-600 dark:text-cyan-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-cyan-900 dark:text-cyan-200 mb-2">Step 6: Sewing In Progress</h3>
<p className="text-sm text-cyan-800 dark:text-cyan-300 mb-3">Your embroidery is being stitched. Monitor the progress below.</p>
<ul className="list-disc list-inside text-sm text-cyan-700 dark:text-cyan-300 space-y-1">
<li>Watch the progress bar and current stitch count</li>
<li>The machine will pause when a color change is needed</li>
<li>Do not leave the machine unattended</li>
</ul>
</div>
</div>
</div>
);
case MachineStatus.COLOR_CHANGE_WAIT:
return (
<div className="bg-yellow-50 dark:bg-yellow-900/95 border-l-4 border-yellow-600 dark:border-yellow-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-yellow-900 dark:text-yellow-200 mb-2">Thread Change Required</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-3">The machine needs a different thread color to continue.</p>
<ul className="list-disc list-inside text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
<li>Check the color blocks section to see which thread is needed</li>
<li>Change to the correct thread color</li>
<li><strong>Press the button on your machine</strong> to resume sewing</li>
</ul>
</div>
</div>
</div>
);
case MachineStatus.PAUSE:
case MachineStatus.STOP:
case MachineStatus.SEWING_INTERRUPTION:
return (
<div className="bg-yellow-50 dark:bg-yellow-900/95 border-l-4 border-yellow-600 dark:border-yellow-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-yellow-900 dark:text-yellow-200 mb-2">Sewing Paused</h3>
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-3">The embroidery has been paused or interrupted.</p>
<ul className="list-disc list-inside text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
<li>Check if everything is okay with the machine</li>
<li>Click "Resume Sewing" when ready to continue</li>
<li>The machine will pick up where it left off</li>
</ul>
</div>
</div>
</div>
);
case MachineStatus.SEWING_COMPLETE:
return (
<div className="bg-green-50 dark:bg-green-900/95 border-l-4 border-green-600 dark:border-green-500 p-4 rounded-lg shadow-lg backdrop-blur-sm">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-6 h-6 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-base font-semibold text-green-900 dark:text-green-200 mb-2">Step 7: Embroidery Complete!</h3>
<p className="text-sm text-green-800 dark:text-green-300 mb-3">Your embroidery is finished. Great work!</p>
<ul className="list-disc list-inside text-sm text-green-700 dark:text-green-300 space-y-1">
<li>Remove the hoop from the machine</li>
<li>Press the Accept button on the machine</li>
<li>Carefully remove your finished embroidery</li>
<li>Trim any jump stitches or loose threads</li>
<li>Click "Delete Pattern" to start a new project</li>
</ul>
</div>
</div>
</div>
);
default:
return null;
}
};
// Render floating overlay
return (
<>
{/* Collapsed state - small info button in bottom-right */}
{!isExpanded && (
<div className="fixed bottom-4 right-4 z-50 animate-fadeIn">
<button
onClick={() => setIsExpanded(true)}
className="w-12 h-12 bg-blue-600 dark:bg-blue-700 hover:bg-blue-700 dark:hover:bg-blue-600 text-white rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110 animate-pulse"
aria-label="Show next step guide"
title="Show next step guide"
>
<InformationCircleIcon className="w-7 h-7" />
</button>
</div>
)}
{/* Expanded state - floating overlay */}
{isExpanded && (
<div className="fixed bottom-4 right-4 z-50 w-[90vw] max-w-[400px] max-h-[40vh] overflow-y-auto animate-slideInRight">
<div className="relative rounded-lg overflow-hidden shadow-xl">
{/* Close button */}
<button
onClick={() => setIsExpanded(false)}
className="absolute top-2 right-2 z-10 w-6 h-6 bg-white/90 dark:bg-gray-800/90 hover:bg-white dark:hover:bg-gray-700 rounded-full flex items-center justify-center transition-colors shadow-md"
aria-label="Minimize guide"
title="Minimize guide"
>
<XMarkIcon className="w-4 h-4 text-gray-700 dark:text-gray-300" />
</button>
{/* Content */}
{renderContent()}
</div>
</div>
)}
</>
);
}

View file

@ -1,11 +1,16 @@
import { CheckCircleIcon } from '@heroicons/react/24/solid';
import { useState, useRef, useEffect } from 'react';
import { CheckCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid';
import { MachineStatus } from '../types/machine';
import { getErrorDetails } from '../utils/errorCodeHelpers';
interface WorkflowStepperProps {
machineStatus: MachineStatus;
isConnected: boolean;
hasPattern: boolean;
patternUploaded: boolean;
hasError?: boolean;
errorMessage?: string;
errorCode?: number;
}
interface Step {
@ -24,6 +29,183 @@ const steps: Step[] = [
{ id: 7, label: 'Complete', description: 'Finish and remove' },
];
// Helper function to get guide content for a step
function getGuideContent(
stepId: number,
machineStatus: MachineStatus,
hasError: boolean,
errorCode?: number,
errorMessage?: string
) {
// Check for errors first
if (hasError) {
const errorDetails = getErrorDetails(errorCode);
if (errorDetails?.isInformational) {
return {
type: 'info' as const,
title: errorDetails.title,
description: errorDetails.description,
items: errorDetails.solutions || []
};
}
return {
type: 'error' as const,
title: errorDetails?.title || 'Error Occurred',
description: errorDetails?.description || errorMessage || 'An error occurred. Please check the machine and try again.',
items: errorDetails?.solutions || [],
errorCode
};
}
// 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: 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 3:
return {
type: 'info' as const,
title: 'Step 3: 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 4:
// 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 4: 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 5:
return {
type: 'success' as const,
title: 'Step 5: 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 6:
// 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 6: 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 7:
return {
type: 'success' as const,
title: 'Step 7: 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;
if (!hasPattern) return 2;
@ -55,8 +237,53 @@ function getCurrentStep(machineStatus: MachineStatus, isConnected: boolean, hasP
}
}
export function WorkflowStepper({ machineStatus, isConnected, hasPattern, patternUploaded }: WorkflowStepperProps) {
export function WorkflowStepper({
machineStatus,
isConnected,
hasPattern,
patternUploaded,
hasError = false,
errorMessage,
errorCode
}: WorkflowStepperProps) {
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
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
// Check if click was on a step circle
const clickedStep = Object.values(stepRefs.current).find(ref =>
ref?.contains(event.target as Node)
);
if (!clickedStep) {
setShowPopover(false);
}
}
};
if (showPopover) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showPopover]);
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">
@ -94,13 +321,19 @@ export function WorkflowStepper({ machineStatus, isConnected, hasPattern, patter
>
{/* 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-green-500 dark:bg-green-600 border-green-400 dark:border-green-500 text-white shadow-green-500/30 dark:shadow-green-600/30' : ''}
${isCurrent ? 'bg-blue-600 dark:bg-blue-700 border-blue-500 dark:border-blue-600 text-white scale-105 lg:scale-110 shadow-blue-600/40 dark:shadow-blue-700/40 ring-2 ring-blue-300 dark:ring-blue-500 ring-offset-2 dark:ring-offset-gray-900' : ''}
${isUpcoming ? 'bg-blue-700 dark:bg-blue-800 border-blue-500/30 dark:border-blue-600/30 text-blue-200/70 dark:text-blue-300/70' : ''}
${showPopover && popoverStep === step.id ? 'ring-4 ring-white dark:ring-gray-800' : ''}
`}
aria-label={`${step.label}: ${isComplete ? 'completed' : isCurrent ? 'current' : 'upcoming'}`}
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" />
@ -121,6 +354,91 @@ export function WorkflowStepper({ machineStatus, isConnected, hasPattern, patter
);
})}
</div>
{/* Popover */}
{showPopover && popoverStep !== null && stepRefs.current[popoverStep] && (
<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, hasError, errorCode, errorMessage);
if (!content) return null;
const colorClasses = {
info: 'bg-blue-50 dark:bg-blue-900/95 border-blue-600 dark:border-blue-500',
success: 'bg-green-50 dark:bg-green-900/95 border-green-600 dark:border-green-500',
warning: 'bg-yellow-50 dark:bg-yellow-900/95 border-yellow-600 dark:border-yellow-500',
error: 'bg-red-50 dark:bg-red-900/95 border-red-600 dark:border-red-500',
progress: 'bg-cyan-50 dark:bg-cyan-900/95 border-cyan-600 dark:border-cyan-500'
};
const iconColorClasses = {
info: 'text-blue-600 dark:text-blue-400',
success: 'text-green-600 dark:text-green-400',
warning: 'text-yellow-600 dark:text-yellow-400',
error: 'text-red-600 dark:text-red-400',
progress: 'text-cyan-600 dark:text-cyan-400'
};
const textColorClasses = {
info: 'text-blue-900 dark:text-blue-200',
success: 'text-green-900 dark:text-green-200',
warning: 'text-yellow-900 dark:text-yellow-200',
error: 'text-red-900 dark:text-red-200',
progress: 'text-cyan-900 dark:text-cyan-200'
};
const descColorClasses = {
info: 'text-blue-800 dark:text-blue-300',
success: 'text-green-800 dark:text-green-300',
warning: 'text-yellow-800 dark:text-yellow-300',
error: 'text-red-800 dark:text-red-300',
progress: 'text-cyan-800 dark:text-cyan-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 === 'error' ? 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>
)}
{content.type === 'error' && content.errorCode !== undefined && (
<p className={`text-xs ${descColorClasses[content.type]} mt-3 font-mono`}>
Error Code: 0x{content.errorCode.toString(16).toUpperCase().padStart(2, '0')}
</p>
)}
</div>
</div>
</div>
);
})()}
</div>
)}
</div>
);
}

View file

@ -152,6 +152,7 @@ export function useBrotherMachine() {
setPatternInfo(null);
setSewingProgress(null);
setError(null);
setMachineError(SewingMachineError.None);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to disconnect");
}