fix: Improve step control UX and fix machine error display

- Consolidate progress stats into 3 cards (stitches, time, speed)
- Keep rollback info visible after machine clears error while paused
- Remove Resume/Start Sewing buttons in STOP state (error must be
  resolved on machine first)
- Use adjustedStitchIndex for progress display to prevent desync
- Make step control layout stable (always render all buttons)
- Reduce polling interval from 500ms to 1000ms during sewing
- Fix machine errors (e.g. HoopError) not showing in error badge
  when there was no accompanying string error message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik 2026-03-26 12:26:05 +01:00
parent 7250e0e586
commit 4fd8ad284f
7 changed files with 85 additions and 117 deletions

View file

@ -187,7 +187,9 @@ export function AppHeader() {
<button <button
className={cn( className={cn(
"inline-flex items-center rounded-full border border-transparent bg-destructive text-white px-2.5 py-1.5 text-xs font-semibold gap-1.5 cursor-pointer hover:bg-destructive/90 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2", "inline-flex items-center rounded-full border border-transparent bg-destructive text-white px-2.5 py-1.5 text-xs font-semibold gap-1.5 cursor-pointer hover:bg-destructive/90 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2",
machineErrorMessage || pyodideError machineErrorMessage ||
pyodideError ||
hasError(machineError)
? "animate-pulse hover:animate-none" ? "animate-pulse hover:animate-none"
: "invisible pointer-events-none", : "invisible pointer-events-none",
)} )}
@ -228,7 +230,9 @@ export function AppHeader() {
</PopoverTrigger> </PopoverTrigger>
{/* Error popover content - unchanged */} {/* Error popover content - unchanged */}
{(machineErrorMessage || pyodideError) && ( {(machineErrorMessage ||
pyodideError ||
hasError(machineError)) && (
<ErrorPopoverContent <ErrorPopoverContent
machineError={ machineError={
machineError != 0xdd ? machineError : undefined machineError != 0xdd ? machineError : undefined

View file

@ -78,19 +78,18 @@ export function ProgressMonitor() {
: patternInfo.totalStitches : patternInfo.totalStitches
: 0; : 0;
// Use adjustedStitchIndex (from step control) when available, otherwise machine-reported
const currentStitch =
adjustedStitchIndex ?? sewingProgress?.currentStitch ?? 0;
const progressPercent = const progressPercent =
totalStitches > 0 totalStitches > 0 ? (currentStitch / totalStitches) * 100 : 0;
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
: 0;
// Calculate color block information from decoded penStitches // Calculate color block information from decoded penStitches
const colorBlocks = useMemo( const colorBlocks = useMemo(
() => calculateColorBlocks(displayPattern), () => calculateColorBlocks(displayPattern),
[displayPattern], [displayPattern],
); );
// Determine current color block based on current stitch
const currentStitch = sewingProgress?.currentStitch || 0;
const currentBlockIndex = findCurrentBlockIndex(colorBlocks, currentStitch); const currentBlockIndex = findCurrentBlockIndex(colorBlocks, currentStitch);
// Calculate time based on color blocks (matches Brother app calculation) // Calculate time based on color blocks (matches Brother app calculation)
@ -127,7 +126,9 @@ export function ProgressMonitor() {
{/* Pattern Info */} {/* Pattern Info */}
{patternInfo && ( {patternInfo && (
<ProgressStats <ProgressStats
currentStitch={currentStitch}
totalStitches={totalStitches} totalStitches={totalStitches}
elapsedMinutes={elapsedMinutes}
totalMinutes={totalMinutes} totalMinutes={totalMinutes}
speed={patternInfo.speed} speed={patternInfo.speed}
/> />
@ -135,13 +136,7 @@ export function ProgressMonitor() {
{/* Progress Bar */} {/* Progress Bar */}
{sewingProgress && ( {sewingProgress && (
<ProgressSection <ProgressSection progressPercent={progressPercent} />
currentStitch={sewingProgress.currentStitch}
totalStitches={totalStitches}
elapsedMinutes={elapsedMinutes}
totalMinutes={totalMinutes}
progressPercent={progressPercent}
/>
)} )}
{/* Color Blocks */} {/* Color Blocks */}

View file

@ -1,49 +1,22 @@
/** /**
* ProgressSection Component * ProgressSection Component
* *
* Displays the progress bar and current/total stitch information * Displays the progress bar
*/ */
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
interface ProgressSectionProps { interface ProgressSectionProps {
currentStitch: number;
totalStitches: number;
elapsedMinutes: number;
totalMinutes: number;
progressPercent: number; progressPercent: number;
} }
export function ProgressSection({ export function ProgressSection({ progressPercent }: ProgressSectionProps) {
currentStitch,
totalStitches,
elapsedMinutes,
totalMinutes,
progressPercent,
}: ProgressSectionProps) {
return ( return (
<div className="mb-3"> <div className="mb-3">
<Progress <Progress
value={progressPercent} value={progressPercent}
className="h-3 mb-2 [&>div]:bg-gradient-to-r [&>div]:from-accent-600 [&>div]:to-accent-700 dark:[&>div]:from-accent-600 dark:[&>div]:to-accent-800" className="h-3 [&>div]:bg-gradient-to-r [&>div]:from-accent-600 [&>div]:to-accent-700 dark:[&>div]:from-accent-600 dark:[&>div]:to-accent-800"
/> />
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
Current Stitch
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{currentStitch.toLocaleString()} / {totalStitches.toLocaleString()}
</span>
</div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Time</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{elapsedMinutes} / {totalMinutes} min
</span>
</div>
</div>
</div> </div>
); );
} }

View file

@ -1,36 +1,36 @@
/** /**
* ProgressStats Component * ProgressStats Component
* *
* Displays three stat cards: total stitches, total time, and speed * Displays three stat cards: stitches (current/total), time (elapsed/total), and speed
*/ */
interface ProgressStatsProps { interface ProgressStatsProps {
currentStitch: number;
totalStitches: number; totalStitches: number;
elapsedMinutes: number;
totalMinutes: number; totalMinutes: number;
speed: number; speed: number;
} }
export function ProgressStats({ export function ProgressStats({
currentStitch,
totalStitches, totalStitches,
elapsedMinutes,
totalMinutes, totalMinutes,
speed, speed,
}: ProgressStatsProps) { }: ProgressStatsProps) {
return ( return (
<div className="grid grid-cols-3 gap-2 text-xs mb-3"> <div className="grid grid-cols-3 gap-2 text-xs mb-3">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block"> <span className="text-gray-600 dark:text-gray-400 block">Stitches</span>
Total Stitches
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{totalStitches.toLocaleString()} {currentStitch.toLocaleString()} / {totalStitches.toLocaleString()}
</span> </span>
</div> </div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block"> <span className="text-gray-600 dark:text-gray-400 block">Time</span>
Total Time
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{totalMinutes} min {elapsedMinutes} / {totalMinutes} min
</span> </span>
</div> </div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded"> <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">

View file

@ -1,7 +1,7 @@
/** /**
* StitchStepControl Component * StitchStepControl Component
* *
* Manual stitch position control shown when machine is paused/stopped/interrupted. * Compact stitch position control shown when machine is paused/stopped/interrupted.
* Allows stepping forward/backward by 1, 10, or 100 stitches, * Allows stepping forward/backward by 1, 10, or 100 stitches,
* jumping to thread color boundaries, and resetting to current position. * jumping to thread color boundaries, and resetting to current position.
*/ */
@ -15,7 +15,10 @@ import {
ArrowUturnLeftIcon, ArrowUturnLeftIcon,
} from "@heroicons/react/24/solid"; } from "@heroicons/react/24/solid";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getErrorStitchRollback, getErrorMessage } from "../../utils/errorCodeHelpers"; import {
getErrorStitchRollback,
getErrorMessage,
} from "../../utils/errorCodeHelpers";
import type { ColorBlock } from "./types"; import type { ColorBlock } from "./types";
import { findCurrentBlockIndex } from "../../utils/colorBlockHelpers"; import { findCurrentBlockIndex } from "../../utils/colorBlockHelpers";
@ -42,7 +45,6 @@ export function StitchStepControl({
}: StitchStepControlProps) { }: StitchStepControlProps) {
const displayStitch = adjustedStitchIndex ?? currentStitch; const displayStitch = adjustedStitchIndex ?? currentStitch;
// Find the start of the current thread color block
const handleGoToThreadStart = () => { const handleGoToThreadStart = () => {
const blockIndex = findCurrentBlockIndex(colorBlocks, displayStitch); const blockIndex = findCurrentBlockIndex(colorBlocks, displayStitch);
if (blockIndex >= 0) { if (blockIndex >= 0) {
@ -50,14 +52,12 @@ export function StitchStepControl({
} }
}; };
// Reset to the position when the machine was paused (after auto-rollback, before manual adjustments)
const handleGoToPausedStitch = () => { const handleGoToPausedStitch = () => {
if (pausedStitchIndex !== null) { if (pausedStitchIndex !== null) {
onSetPosition(pausedStitchIndex); onSetPosition(pausedStitchIndex);
} }
}; };
// Rollback info text
const rollbackAmount = lastRolledBackError const rollbackAmount = lastRolledBackError
? getErrorStitchRollback(lastRolledBackError) ? getErrorStitchRollback(lastRolledBackError)
: null; : null;
@ -65,31 +65,48 @@ export function StitchStepControl({
? getErrorMessage(lastRolledBackError) ? getErrorMessage(lastRolledBackError)
: null; : null;
return ( const showGoToPaused =
<div className="mb-3 bg-gray-200 dark:bg-gray-700/50 p-3 rounded-lg"> pausedStitchIndex !== null && displayStitch !== pausedStitchIndex;
<div className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2">
Stitch Position
</div>
{/* Current position display */} return (
<div className="text-center mb-2"> <div className="mb-3 bg-gray-200 dark:bg-gray-700/50 px-3 py-2 rounded-lg">
<span className="text-lg font-bold text-gray-900 dark:text-gray-100 tabular-nums"> {/* Header: label + stitch count on one line */}
<div className="flex items-baseline justify-between mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
Stitch Position
</span>
<span>
<span className="text-sm font-bold text-gray-900 dark:text-gray-100 tabular-nums">
{displayStitch.toLocaleString()} {displayStitch.toLocaleString()}
</span> </span>
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1"> <span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
/ {totalStitches.toLocaleString()} / {totalStitches.toLocaleString()}
</span> </span>
</span>
</div> </div>
{/* Rollback info */} {/* Rollback info */}
{rollbackAmount !== null && rollbackErrorName && ( {rollbackAmount !== null && rollbackErrorName && (
<div className="text-xs text-amber-600 dark:text-amber-400 text-center mb-2"> <div className="text-xs text-amber-600 dark:text-amber-400 text-center mb-1.5">
Moved back {rollbackAmount} stitches due to {rollbackErrorName.toLowerCase()} Moved back {rollbackAmount} stitches (
{rollbackErrorName.toLowerCase()})
</div> </div>
)} )}
{/* Step buttons */} {/* Step buttons + navigation in one row */}
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
{colorBlocks.length > 0 && (
<Button
variant="outline"
size="icon-sm"
onClick={handleGoToThreadStart}
title="Go to the beginning of the selected thread color"
aria-label="Go to the beginning of the selected thread color"
>
<SwatchIcon className="w-4 h-4" />
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="icon-sm" size="icon-sm"
@ -153,34 +170,17 @@ export function StitchStepControl({
> >
<ChevronDoubleRightIcon className="w-4 h-4" /> <ChevronDoubleRightIcon className="w-4 h-4" />
</Button> </Button>
</div>
{/* Navigation buttons */}
<div className="flex items-center justify-center gap-1 mt-2">
{colorBlocks.length > 0 && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="icon-sm"
onClick={handleGoToThreadStart}
title="Go to the beginning of the selected thread color"
aria-label="Go to the beginning of the selected thread color"
>
<SwatchIcon className="w-3.5 h-3.5" />
Thread Start
</Button>
)}
{pausedStitchIndex !== null && displayStitch !== pausedStitchIndex && (
<Button
variant="outline"
size="sm"
onClick={handleGoToPausedStitch} onClick={handleGoToPausedStitch}
disabled={!showGoToPaused}
title="Go to the current stitch" title="Go to the current stitch"
aria-label="Go to the current stitch" aria-label="Go to the current stitch"
> >
<ArrowUturnLeftIcon className="w-3.5 h-3.5" /> <ArrowUturnLeftIcon className="w-4 h-4" />
Current Stitch
</Button> </Button>
)}
</div> </div>
</div> </div>
); );

View file

@ -487,13 +487,8 @@ export const useMachineStore = create<MachineState>((set, get) => ({
}); });
} }
} }
// Reset rollback tracking when error clears while still paused // Note: we intentionally do NOT clear lastRolledBackError when the error clears
else if ( // while still paused, so the rollback info text remains visible to the user.
currentState.lastRolledBackError !== null &&
currentState.machineError === SewingMachineError.None
) {
set({ lastRolledBackError: null });
}
// Auto-rollback for thread errors when machine is interrupted or paused mid-sew // Auto-rollback for thread errors when machine is interrupted or paused mid-sew
// Only runs once on entering paused state (when pausedStitchIndex is not yet set) // Only runs once on entering paused state (when pausedStitchIndex is not yet set)
@ -508,9 +503,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
// Snapshot the paused position (after rollback, before manual adjustments) // Snapshot the paused position (after rollback, before manual adjustments)
const postRollbackStitch = const postRollbackStitch =
get().adjustedStitchIndex ?? get().adjustedStitchIndex ?? get().sewingProgress?.currentStitch ?? 0;
get().sewingProgress?.currentStitch ??
0;
set({ pausedStitchIndex: postRollbackStitch }); set({ pausedStitchIndex: postRollbackStitch });
} }

View file

@ -97,10 +97,8 @@ export function canUploadPattern(status: MachineStatus): boolean {
export function canStartSewing(status: MachineStatus): boolean { export function canStartSewing(status: MachineStatus): boolean {
// Only in specific ready states // Only in specific ready states
return ( return (
status === MachineStatus.SEWING_WAIT ||
status === MachineStatus.MASK_TRACE_COMPLETE || status === MachineStatus.MASK_TRACE_COMPLETE ||
status === MachineStatus.PAUSE || status === MachineStatus.PAUSE ||
status === MachineStatus.STOP ||
status === MachineStatus.SEWING_INTERRUPTION status === MachineStatus.SEWING_INTERRUPTION
); );
} }
@ -114,7 +112,10 @@ export function canStartMaskTrace(
status: MachineStatus, status: MachineStatus,
hasSewingProgress = false, hasSewingProgress = false,
): boolean { ): boolean {
if (status === MachineStatus.IDLE || status === MachineStatus.MASK_TRACE_COMPLETE) { if (
status === MachineStatus.IDLE ||
status === MachineStatus.MASK_TRACE_COMPLETE
) {
return true; return true;
} }
// Only allow mask trace in SEWING_WAIT if sewing hasn't started yet // Only allow mask trace in SEWING_WAIT if sewing hasn't started yet
@ -126,12 +127,14 @@ export function canStartMaskTrace(
/** /**
* Determines if sewing can be resumed in the current state. * Determines if sewing can be resumed in the current state.
* Only for interrupted operations (PAUSE, STOP, SEWING_INTERRUPTION). * Only for PAUSE and SEWING_INTERRUPTION - not STOP, which requires
* the user to resolve the error on the machine first.
*/ */
export function canResumeSewing(status: MachineStatus): boolean { export function canResumeSewing(status: MachineStatus): boolean {
// Only in interrupted states return (
const category = getMachineStateCategory(status); status === MachineStatus.PAUSE ||
return category === MachineStateCategory.INTERRUPTED; status === MachineStatus.SEWING_INTERRUPTION
);
} }
/** /**