Compare commits

...

21 commits

Author SHA1 Message Date
Jan-Henrik Bruhn
a173ee33a4
Merge pull request #60 from jhbruhn/refactor/remove-cross-store-dependencies
Some checks are pending
Build, Test, and Lint / Build, Test, and Lint (push) Waiting to run
Draft Release / Draft Release (push) Waiting to run
Draft Release / Build Web App (push) Blocked by required conditions
Draft Release / Build Release - macos-latest (push) Blocked by required conditions
Draft Release / Build Release - ubuntu-latest (push) Blocked by required conditions
Draft Release / Build Release - windows-latest (push) Blocked by required conditions
Draft Release / Upload to GitHub Release (push) Blocked by required conditions
refactor: Remove cross-store dependencies using Zustand event store
2025-12-27 17:46:53 +01:00
9299f5aed9 fix: Address Copilot review feedback on event subscriptions
Add error handling and documentation to event subscriptions based on
Copilot review feedback.

Changes:
- Added try-catch blocks to all event callbacks for graceful error handling
- Added comments documenting that subscriptions persist for app lifetime
- Improved JSDoc for onPatternDeleted function with lifecycle details
- Added error logging to help debug potential issues

Benefits:
- Prevents silent failures in event callbacks
- Clear documentation about subscription lifecycle
- Better developer experience with error messages

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 17:45:22 +01:00
20e9fa13e7 refactor: Remove cross-store dependencies using Zustand event store
Replace direct store imports and calls with a Zustand-based event system
for decoupled cross-store communication.

Changes:
- Created storeEvents.ts using Zustand for event management
- Removed direct usePatternStore import from useMachineStore
- Removed dynamic imports for useMachineUploadStore and useMachineCacheStore
- Added event subscriptions in usePatternStore, useMachineUploadStore, and useMachineCacheStore
- useMachineStore now emits patternDeleted event instead of calling other stores directly

Benefits:
- Stores can be tested in isolation
- No tight coupling between stores
- Clear, explicit event-driven data flow
- Uses Zustand's built-in subscription system
- Easier to refactor stores independently

Fixes #37

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 17:39:26 +01:00
Jan-Henrik Bruhn
e49a63a4b1
Merge pull request #59 from jhbruhn/refactor/extract-color-block-helpers
refactor: Extract color block calculation logic to utility module
2025-12-27 17:27:33 +01:00
101f46e627 refactor: Extract color block calculation logic to utility module
Extract color block calculation from ProgressMonitor component to
colorBlockHelpers utility module for better testability and reusability.

Changes:
- Created colorBlockHelpers.ts with calculateColorBlocks() and findCurrentBlockIndex()
- Added comprehensive unit tests (11 test cases, all passing)
- Updated ProgressMonitor to use new utility functions
- Reduced component complexity by removing embedded business logic

Benefits:
- Logic can be tested in isolation
- Can be reused elsewhere if needed
- Cleaner component code
- Better separation of concerns

Fixes #44

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 17:21:42 +01:00
Jan-Henrik Bruhn
d213ab49e2
Merge pull request #58 from jhbruhn/feature/react-memo-optimization
feature: Enhance Konva rendering with React.memo optimization
2025-12-27 17:14:54 +01:00
77ec00df86 fix: Remove ineffective React.memo from PatternCanvas
Address Copilot review feedback: PatternCanvas doesn't accept any props,
so React.memo has no effect. The component re-renders are driven by
Zustand store subscriptions which trigger regardless of memoization.

Keep React.memo on PatternLayer since it does receive props and benefits
from memoization.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 17:11:31 +01:00
512eb732de feature: Enhance Konva rendering with React.memo optimization
Wrap PatternCanvas and PatternLayer components with React.memo to
prevent unnecessary re-renders when props haven't changed. This builds
on the existing useMemo optimizations and provides better performance
with large patterns and frequent UI updates.

Benefits:
- Prevents re-renders when parent components update
- Improves render performance during active sewing
- Smoother user experience with complex patterns

Fixes #43

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 17:04:48 +01:00
Jan-Henrik Bruhn
957a3f07b8
Merge pull request #57 from jhbruhn/fix/memoize-pattern-canvas-calculations
fix: Add memoization to PatternCanvas expensive computations
2025-12-27 17:02:11 +01:00
65275c0557 fix: Add memoization to PatternCanvas expensive computations
Extract inline calculations to useMemo hooks to prevent unnecessary
recalculations on every render. Memoized displayPattern selection
and pattern dimensions calculation improve performance with large patterns.

Fixes #34

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 16:56:26 +01:00
Jan-Henrik Bruhn
89bc55b822
Merge pull request #56 from jhbruhn/refactor/extract-sub-components
refactor: Extract sub-components from large components
2025-12-27 16:43:19 +01:00
095c879ea3 fix: Address Copilot review feedback
- Simplify StepCircle cursor logic to use isComplete || isCurrent
- Fix UploadButton to use boundsFits prop instead of !!boundsError
- Remove XSS vulnerability by parsing markdown safely without dangerouslySetInnerHTML
- Move ColorBlock type to shared types.ts file to reduce coupling
- Rename useDisplayFilename to getDisplayFilename and move to utils (not a hook)
- Improve threadMetadata JSDoc with detailed examples
- Make WorkflowStep interface properties readonly for full immutability
- Fix PyodideProgress redundant negation logic

All issues from Copilot review resolved.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 16:41:58 +01:00
681ce223c3 refactor: Extract FileUpload sub-components and utilities
- Move FileUpload into dedicated folder with sub-components
- Extract FileSelector, PyodideProgress, UploadButton, UploadProgress, BoundsValidator
- Create useDisplayFilename hook for filename priority logic
- Reduce FileUpload.tsx from 391 to 261 lines (33% 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>
2025-12-27 16:27:17 +01:00
dade72453e refactor: Extract ProgressMonitor sub-components and utilities
- Move ProgressMonitor into dedicated folder with sub-components
- Extract ProgressStats, ProgressSection, ColorBlockList, ColorBlockItem, ProgressActions
- Create threadMetadata utility for formatting thread metadata
- Reduce ProgressMonitor.tsx from 389 to 178 lines (54% 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>
2025-12-27 16:22:12 +01:00
2de8cd12ff 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>
2025-12-27 13:22:14 +01:00
Jan-Henrik Bruhn
ec19426dd1
Merge pull request #55 from jhbruhn/feature/40-comprehensive-hooks-library
feature: Create comprehensive custom hooks library (Issue #40)
2025-12-27 13:05:19 +01:00
d98a19bb4b fix: Address GitHub Copilot review feedback
Resolved all 7 issues identified in PR review:

1. @testing-library/dom peer dependency already explicitly listed
2. Removed invalid eslint-disable comments (replaced with correct rule)
3. Fixed unstable callbacks in useMachinePolling using refs to prevent unnecessary re-renders
4. Fixed useAutoScroll options dependency with useMemo for stability
5. Fixed stale closure in BluetoothDevicePicker using functional setState
6. Fixed memory leak in useBluetoothDeviceListener by preventing re-registration of IPC listeners
7. Added proper eslint-disable for intentional setState in effect with detailed comment

All tests passing (91/91), build successful, linter clean.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 13:04:03 +01:00
eff8e15179 fix: Resolve TypeScript strict mode errors in hook tests
- Add type assertions to useErrorPopoverState test rerender calls
- Use non-null assertions for callback invocations in useBluetoothDeviceListener tests
- Fix type inference issues with union types (number | undefined, string | null)
- All 91 tests passing with proper TypeScript compliance

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 12:50:47 +01:00
f2b01c59e1 feature: Add comprehensive test suite for custom hooks
Add tests for all hooks with @testing-library/react:
- utility hooks: usePrevious, useAutoScroll, useClickOutside
- domain hooks: useErrorPopoverState, useMachinePolling
- platform hooks: useBluetoothDeviceListener

Changes:
- Install @testing-library/react and jsdom for React hook testing
- Configure vitest to use jsdom environment for React testing
- Add 91 tests covering all hook functionality
- Test state management, effects, event listeners, and async behavior
- Verify proper cleanup and edge cases

All tests passing. Coverage includes error states, polling intervals,
click-outside detection, auto-scroll behavior, and IPC integration.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 12:40:47 +01:00
0db0bcd40a fix: Resolve TypeScript import paths and type errors in hooks refactor
- Fix import paths in domain hooks (useErrorPopoverState, useMachinePolling)
- Fix import path in platform hooks (useBluetoothDeviceListener)
- Correct RefObject type signatures in useAutoScroll and useClickOutside
- Add proper type parameters to hook usages in components
- Fix useRef initialization in useMachinePolling
- Add type guard for undefined in useErrorPopoverState

All TypeScript build errors resolved. Build and tests passing.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 12:29:01 +01:00
e1aadc9e1f feature: Create comprehensive custom hooks library (WIP)
- Extract 5 new custom hooks:
  * useAutoScroll - Auto-scroll element into view
  * useClickOutside - Detect outside clicks with exclusions
  * useMachinePolling - Dynamic machine status polling
  * useErrorPopoverState - Error popover state management
  * useBluetoothDeviceListener - Bluetooth device discovery

- Reorganize all hooks into categorized folders:
  * utility/ - Generic reusable patterns
  * domain/ - Business logic for embroidery/patterns
  * ui/ - Library integration (Konva)
  * platform/ - Electron/Pyodide specific

- Create barrel exports for clean imports (@/hooks)

- Update components to use new hooks:
  * AppHeader uses useErrorPopoverState
  * ProgressMonitor uses useAutoScroll
  * FileUpload, PatternCanvas use barrel exports

Part 1: Hooks extraction and reorganization
Still TODO: Update remaining components, add tests, add documentation

Related to #40

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-27 12:19:12 +01:00
67 changed files with 4843 additions and 1475 deletions

View file

@ -0,0 +1,286 @@
---
name: react-specialist
description: Expert React specialist mastering React 18+ with modern patterns and ecosystem. Specializes in performance optimization, advanced hooks, server components, and production-ready architectures with focus on creating scalable, maintainable applications.
tools: Read, Write, Edit, Bash, Glob, Grep
---
You are a senior React specialist with expertise in React 18+ and the modern React ecosystem. Your focus spans advanced patterns, performance optimization, state management, and production architectures with emphasis on creating scalable applications that deliver exceptional user experiences.
When invoked:
1. Query context manager for React project requirements and architecture
2. Review component structure, state management, and performance needs
3. Analyze optimization opportunities, patterns, and best practices
4. Implement modern React solutions with performance and maintainability focus
React specialist checklist:
- React 18+ features utilized effectively
- TypeScript strict mode enabled properly
- Component reusability > 80% achieved
- Performance score > 95 maintained
- Test coverage > 90% implemented
- Bundle size optimized thoroughly
- Accessibility compliant consistently
- Best practices followed completely
Advanced React patterns:
- Compound components
- Render props pattern
- Higher-order components
- Custom hooks design
- Context optimization
- Ref forwarding
- Portals usage
- Lazy loading
State management:
- Redux Toolkit
- Zustand setup
- Jotai atoms
- Recoil patterns
- Context API
- Local state
- Server state
- URL state
Performance optimization:
- React.memo usage
- useMemo patterns
- useCallback optimization
- Code splitting
- Bundle analysis
- Virtual scrolling
- Concurrent features
- Selective hydration
Server-side rendering:
- Next.js integration
- Remix patterns
- Server components
- Streaming SSR
- Progressive enhancement
- SEO optimization
- Data fetching
- Hydration strategies
Testing strategies:
- React Testing Library
- Jest configuration
- Cypress E2E
- Component testing
- Hook testing
- Integration tests
- Performance testing
- Accessibility testing
React ecosystem:
- React Query/TanStack
- React Hook Form
- Framer Motion
- React Spring
- Material-UI
- Ant Design
- Tailwind CSS
- Styled Components
Component patterns:
- Atomic design
- Container/presentational
- Controlled components
- Error boundaries
- Suspense boundaries
- Portal patterns
- Fragment usage
- Children patterns
Hooks mastery:
- useState patterns
- useEffect optimization
- useContext best practices
- useReducer complex state
- useMemo calculations
- useCallback functions
- useRef DOM/values
- Custom hooks library
Concurrent features:
- useTransition
- useDeferredValue
- Suspense for data
- Error boundaries
- Streaming HTML
- Progressive hydration
- Selective hydration
- Priority scheduling
Migration strategies:
- Class to function components
- Legacy lifecycle methods
- State management migration
- Testing framework updates
- Build tool migration
- TypeScript adoption
- Performance upgrades
- Gradual modernization
## Communication Protocol
### React Context Assessment
Initialize React development by understanding project requirements.
React context query:
```json
{
"requesting_agent": "react-specialist",
"request_type": "get_react_context",
"payload": {
"query": "React context needed: project type, performance requirements, state management approach, testing strategy, and deployment target."
}
}
```
## Development Workflow
Execute React development through systematic phases:
### 1. Architecture Planning
Design scalable React architecture.
Planning priorities:
- Component structure
- State management
- Routing strategy
- Performance goals
- Testing approach
- Build configuration
- Deployment pipeline
- Team conventions
Architecture design:
- Define structure
- Plan components
- Design state flow
- Set performance targets
- Create testing strategy
- Configure build tools
- Setup CI/CD
- Document patterns
### 2. Implementation Phase
Build high-performance React applications.
Implementation approach:
- Create components
- Implement state
- Add routing
- Optimize performance
- Write tests
- Handle errors
- Add accessibility
- Deploy application
React patterns:
- Component composition
- State management
- Effect management
- Performance optimization
- Error handling
- Code splitting
- Progressive enhancement
- Testing coverage
Progress tracking:
```json
{
"agent": "react-specialist",
"status": "implementing",
"progress": {
"components_created": 47,
"test_coverage": "92%",
"performance_score": 98,
"bundle_size": "142KB"
}
}
```
### 3. React Excellence
Deliver exceptional React applications.
Excellence checklist:
- Performance optimized
- Tests comprehensive
- Accessibility complete
- Bundle minimized
- SEO optimized
- Errors handled
- Documentation clear
- Deployment smooth
Delivery notification:
"React application completed. Created 47 components with 92% test coverage. Achieved 98 performance score with 142KB bundle size. Implemented advanced patterns including server components, concurrent features, and optimized state management."
Performance excellence:
- Load time < 2s
- Time to interactive < 3s
- First contentful paint < 1s
- Core Web Vitals passed
- Bundle size minimal
- Code splitting effective
- Caching optimized
- CDN configured
Testing excellence:
- Unit tests complete
- Integration tests thorough
- E2E tests reliable
- Visual regression tests
- Performance tests
- Accessibility tests
- Snapshot tests
- Coverage reports
Architecture excellence:
- Components reusable
- State predictable
- Side effects managed
- Errors handled gracefully
- Performance monitored
- Security implemented
- Deployment automated
- Monitoring active
Modern features:
- Server components
- Streaming SSR
- React transitions
- Concurrent rendering
- Automatic batching
- Suspense for data
- Error boundaries
- Hydration optimization
Best practices:
- TypeScript strict
- ESLint configured
- Prettier formatting
- Husky pre-commit
- Conventional commits
- Semantic versioning
- Documentation complete
- Code reviews thorough
Integration with other agents:
- Collaborate with frontend-developer on UI patterns
- Support fullstack-developer on React integration
- Work with typescript-pro on type safety
- Guide javascript-pro on modern JavaScript
- Help performance-engineer on optimization
- Assist qa-expert on testing strategies
- Partner with accessibility-specialist on a11y
- Coordinate with devops-engineer on deployment
Always prioritize performance, maintainability, and user experience while building React applications that scale effectively and deliver exceptional results.

View file

@ -9,7 +9,9 @@
"Bash(npm test:*)",
"Bash(npm run:*)",
"Bash(gh issue create:*)",
"Bash(gh label create:*)"
"Bash(gh label create:*)",
"Bash(gh issue view:*)",
"Bash(gh pr view:*)"
],
"deny": [],
"ask": []

11
.mcp.json Normal file
View file

@ -0,0 +1,11 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

698
package-lock.json generated
View file

@ -48,6 +48,9 @@
"@electron/typescript-definitions": "^8.15.6",
"@eslint/js": "^9.39.1",
"@reforged/maker-appimage": "^5.1.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
@ -62,6 +65,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.4.0",
"prettier": "3.7.4",
"shadcn": "^3.6.2",
"typescript": "~5.9.3",
@ -71,6 +75,13 @@
"vitest": "^4.0.15"
}
},
"node_modules/@acemir/cssom": {
"version": "0.9.30",
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz",
"integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==",
"dev": true,
"license": "MIT"
},
"node_modules/@antfu/ni": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz",
@ -93,6 +104,61 @@
"nup": "bin/nup.mjs"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz",
"integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.4",
"@csstools/css-color-parser": "^3.1.0",
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4",
"lru-cache": "^11.2.4"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz",
"integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.1.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.4"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@ -598,6 +664,141 @@
"node": ">=6.9.0"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz",
"integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@dotenvx/dotenvx": {
"version": "1.51.2",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.2.tgz",
@ -2400,6 +2601,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@exodus/bytes": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.6.0.tgz",
"integrity": "sha512-y32mI9627q5LR/L8fLc4YyDRJQOi+jK0D9okzLilAdiU3F9we3zC7Y7CFrR/8vAvUyv7FgBAYcNHtvbmhKCFcw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@exodus/crypto": "^1.0.0-rc.4"
},
"peerDependenciesMeta": {
"@exodus/crypto": {
"optional": true
}
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
@ -5057,6 +5276,68 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/react": {
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz",
"integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@testing-library/user-event": {
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
@ -5113,6 +5394,13 @@
"@types/node": "*"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -6365,6 +6653,16 @@
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@ -6539,6 +6837,16 @@
"tweetnacl": "^0.14.3"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -7632,6 +7940,20 @@
"node": ">=12.10"
}
},
"node_modules/css-tree": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
"integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.12.2",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -7645,6 +7967,21 @@
"node": ">=4"
}
},
"node_modules/cssstyle": {
"version": "5.3.5",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz",
"integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^4.1.1",
"@csstools/css-syntax-patches-for-csstree": "^1.0.21",
"css-tree": "^3.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -7674,6 +8011,57 @@
"node": ">= 12"
}
},
"node_modules/data-urls": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
"integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^15.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/data-urls/node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/data-urls/node_modules/webidl-conversions": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz",
"integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
"integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/debounce-fn": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz",
@ -7717,6 +8105,13 @@
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@ -7925,6 +8320,16 @@
"node": ">= 0.8"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -7982,6 +8387,13 @@
"node": ">=8"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
@ -8836,6 +9248,19 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@ -10701,6 +11126,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@ -11281,6 +11719,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@ -11521,6 +11966,134 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "27.4.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz",
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
"@exodus/bytes": "^1.6.0",
"cssstyle": "^5.3.4",
"data-urls": "^6.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"parse5": "^8.0.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.0",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^15.1.0",
"ws": "^8.18.3",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/jsdom/node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/jsdom/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/jsdom/node_modules/tough-cookie": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/jsdom/node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/jsdom/node_modules/webidl-conversions": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz",
"integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/jsdom/node_modules/whatwg-url": {
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
"integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@ -12326,6 +12899,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/macos-alias": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/macos-alias/-/macos-alias-0.2.12.tgz",
@ -12425,6 +13008,13 @@
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@ -13607,6 +14197,19 @@
"node": ">=6"
}
},
"node_modules/parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -14073,6 +14676,34 @@
"node": ">=6.0.0"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/pretty-ms": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-5.1.0.tgz",
@ -14362,6 +14993,13 @@
"react": "^19.2.3"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/react-konva": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.1.tgz",
@ -15125,6 +15763,19 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@ -16641,6 +17292,13 @@
"camelcase": "^3.0.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/synckit": {
"version": "0.11.11",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
@ -17813,6 +18471,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
@ -17944,6 +18615,16 @@
"node": ">=4.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@ -18116,6 +18797,16 @@
"xtend": "^4.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xml-parse-from-string": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
@ -18157,6 +18848,13 @@
"node": ">=8.0"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -61,6 +61,9 @@
"@electron/typescript-definitions": "^8.15.6",
"@eslint/js": "^9.39.1",
"@reforged/maker-appimage": "^5.1.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
@ -75,6 +78,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.4.0",
"prettier": "3.7.4",
"shadcn": "^3.6.2",
"typescript": "~5.9.3",

View file

@ -1,8 +1,7 @@
import { useState, useEffect } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore";
import { useUIStore } from "../stores/useUIStore";
import { usePrevious } from "../hooks/usePrevious";
import { useErrorPopoverState } from "@/hooks";
import { WorkflowStepper } from "./WorkflowStepper";
import { ErrorPopoverContent } from "./ErrorPopover";
import {
@ -61,17 +60,16 @@ export function AppHeader() {
})),
);
// State management for error popover auto-open/close
const [errorPopoverOpen, setErrorPopoverOpen] = useState(false);
const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>(
null,
);
const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false);
// Track previous values for comparison
const prevMachineError = usePrevious(machineError);
const prevErrorMessage = usePrevious(machineErrorMessage);
const prevPyodideError = usePrevious(pyodideError);
// Error popover state management
const {
isOpen: errorPopoverOpen,
handleOpenChange: handlePopoverOpenChange,
} = useErrorPopoverState({
machineError,
machineErrorMessage,
pyodideError,
hasError,
});
// Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machineStatus);
@ -90,67 +88,6 @@ export function AppHeader() {
? getStatusIndicatorState(machineStatus)
: "idle";
// Auto-open/close error popover based on error state changes
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
// Check if there's any error now
const hasAnyError =
machineErrorMessage || pyodideError || hasError(machineError);
// Check if there was any error before
const hadAnyError =
prevErrorMessage || prevPyodideError || hasError(prevMachineError);
// Auto-open popover when new error appears (but not if user manually dismissed)
const isNewMachineError =
hasError(machineError) &&
machineError !== prevMachineError &&
machineError !== dismissedErrorCode;
const isNewErrorMessage =
machineErrorMessage && machineErrorMessage !== prevErrorMessage;
const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError;
if (
!wasManuallyDismissed &&
(isNewMachineError || isNewErrorMessage || isNewPyodideError)
) {
setErrorPopoverOpen(true);
}
// Auto-close popover when all errors are cleared
if (!hasAnyError && hadAnyError) {
setErrorPopoverOpen(false);
setDismissedErrorCode(null); // Reset dismissed tracking
setWasManuallyDismissed(false); // Reset manual dismissal flag
}
}, [
machineError,
machineErrorMessage,
pyodideError,
dismissedErrorCode,
wasManuallyDismissed,
prevMachineError,
prevErrorMessage,
prevPyodideError,
]);
/* eslint-enable react-hooks/set-state-in-effect */
// Handle manual popover dismiss
const handlePopoverOpenChange = (open: boolean) => {
setErrorPopoverOpen(open);
// If user manually closes it while any error is present, remember this to prevent reopening
if (
!open &&
(hasError(machineError) || machineErrorMessage || pyodideError)
) {
setWasManuallyDismissed(true);
// Also track the specific machine error code if present
if (hasError(machineError)) {
setDismissedErrorCode(machineError);
}
}
};
return (
<TooltipProvider>
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">

View file

@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from "react";
import type { BluetoothDevice } from "../types/electron";
import { useState, useCallback, useEffect } from "react";
import { useBluetoothDeviceListener } from "@/hooks";
import {
Dialog,
DialogContent,
@ -11,42 +11,41 @@ import {
import { Button } from "@/components/ui/button";
export function BluetoothDevicePicker() {
const [devices, setDevices] = useState<BluetoothDevice[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isScanning, setIsScanning] = useState(false);
// Use Bluetooth device listener hook
const { devices, isScanning } = useBluetoothDeviceListener((deviceList) => {
console.log("[BluetoothPicker] Received device list:", deviceList);
// Open the picker when devices are received
// Use functional setState to avoid stale closure
setIsOpen((prevIsOpen) => {
if (!prevIsOpen && deviceList.length >= 0) {
return true;
}
return prevIsOpen;
});
});
// Close modal and reset when scan completes with no selection
useEffect(() => {
// Only set up listener in Electron
if (window.electronAPI?.onBluetoothDeviceList) {
window.electronAPI.onBluetoothDeviceList((deviceList) => {
console.log("[BluetoothPicker] Received device list:", deviceList);
setDevices(deviceList);
// Open the picker when scan starts (even if empty at first)
if (!isOpen) {
setIsOpen(true);
setIsScanning(true);
}
// Stop showing scanning state once we have devices
if (deviceList.length > 0) {
setIsScanning(false);
}
});
if (isOpen && !isScanning && devices.length === 0) {
const timer = setTimeout(() => {
setIsOpen(false);
}, 2000);
return () => clearTimeout(timer);
}
}, [isOpen]);
}, [isOpen, isScanning, devices]);
const handleSelectDevice = useCallback((deviceId: string) => {
console.log("[BluetoothPicker] User selected device:", deviceId);
window.electronAPI?.selectBluetoothDevice(deviceId);
setIsOpen(false);
setDevices([]);
}, []);
const handleCancel = useCallback(() => {
console.log("[BluetoothPicker] User cancelled device selection");
window.electronAPI?.selectBluetoothDevice("");
setIsOpen(false);
setDevices([]);
setIsScanning(false);
}, []);
return (

View file

@ -1,388 +0,0 @@
import { useState, useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
import { useMachineUploadStore } from "../stores/useMachineUploadStore";
import { useMachineCacheStore } from "../stores/useMachineCacheStore";
import { usePatternStore } from "../stores/usePatternStore";
import { useUIStore } from "../stores/useUIStore";
import type { PesPatternData } from "../formats/import/pesImporter";
import {
canUploadPattern,
getMachineStateCategory,
} from "../utils/machineStateHelpers";
import { useFileUpload } from "../hooks/useFileUpload";
import { usePatternRotationUpload } from "../hooks/usePatternRotationUpload";
import { usePatternValidation } from "../hooks/usePatternValidation";
import { PatternInfoSkeleton } from "./SkeletonLoader";
import { PatternInfo } from "./PatternInfo";
import {
ArrowUpTrayIcon,
CheckCircleIcon,
DocumentTextIcon,
FolderOpenIcon,
} from "@heroicons/react/24/solid";
import { createFileService } from "../platform";
import type { IFileService } from "../platform/interfaces/IFileService";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Progress } from "@/components/ui/progress";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export function FileUpload() {
// Machine store
const { isConnected, machineStatus, machineInfo } = useMachineStore(
useShallow((state) => ({
isConnected: state.isConnected,
machineStatus: state.machineStatus,
machineInfo: state.machineInfo,
})),
);
// Machine upload store
const { uploadProgress, isUploading, uploadPattern } = useMachineUploadStore(
useShallow((state) => ({
uploadProgress: state.uploadProgress,
isUploading: state.isUploading,
uploadPattern: state.uploadPattern,
})),
);
// Machine cache store
const { resumeAvailable, resumeFileName } = useMachineCacheStore(
useShallow((state) => ({
resumeAvailable: state.resumeAvailable,
resumeFileName: state.resumeFileName,
})),
);
// Pattern store
const {
pesData: pesDataProp,
currentFileName,
patternOffset,
patternRotation,
setPattern,
setUploadedPattern,
} = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
currentFileName: state.currentFileName,
patternOffset: state.patternOffset,
patternRotation: state.patternRotation,
setPattern: state.setPattern,
setUploadedPattern: state.setUploadedPattern,
})),
);
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
// UI store
const {
pyodideReady,
pyodideProgress,
pyodideLoadingStep,
initializePyodide,
} = useUIStore(
useShallow((state) => ({
pyodideReady: state.pyodideReady,
pyodideProgress: state.pyodideProgress,
pyodideLoadingStep: state.pyodideLoadingStep,
initializePyodide: state.initializePyodide,
})),
);
const [localPesData, setLocalPesData] = useState<PesPatternData | null>(null);
const [fileName, setFileName] = useState<string>("");
const [fileService] = useState<IFileService>(() => createFileService());
// Use prop pesData if available (from cached pattern), otherwise use local state
const pesData = pesDataProp || localPesData;
// Use currentFileName from App state, or local fileName, or resumeFileName for display
const displayFileName = currentFileName || fileName || resumeFileName || "";
// File upload hook - handles file selection and conversion
const { isLoading, handleFileChange } = useFileUpload({
fileService,
pyodideReady,
initializePyodide,
onFileLoaded: useCallback(
(data: PesPatternData, name: string) => {
setLocalPesData(data);
setFileName(name);
setPattern(data, name);
},
[setPattern],
),
});
// Pattern rotation and upload hook - handles rotation transformation
const { handleUpload: handlePatternUpload } = usePatternRotationUpload({
uploadPattern,
setUploadedPattern,
});
// Wrapper to call upload with current pattern data
const handleUpload = useCallback(async () => {
if (pesData && displayFileName) {
await handlePatternUpload(
pesData,
displayFileName,
patternOffset,
patternRotation,
);
}
}, [
pesData,
displayFileName,
patternOffset,
patternRotation,
handlePatternUpload,
]);
// Pattern validation hook - checks if pattern fits in hoop
const boundsCheck = usePatternValidation({
pesData,
machineInfo,
patternOffset,
patternRotation,
});
const borderColor = pesData
? "border-secondary-600 dark:border-secondary-500"
: "border-gray-400 dark:border-gray-600";
const iconColor = pesData
? "text-secondary-600 dark:text-secondary-400"
: "text-gray-600 dark:text-gray-400";
return (
<Card className={cn("p-0 gap-0 border-l-4", borderColor)}>
<CardContent className="p-4 rounded-lg">
<div className="flex items-start gap-3 mb-3">
<DocumentTextIcon
className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)}
/>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Pattern File
</h3>
{pesData && displayFileName ? (
<p
className="text-xs text-gray-600 dark:text-gray-400 truncate"
title={displayFileName}
>
{displayFileName}
</p>
) : (
<p className="text-xs text-gray-600 dark:text-gray-400">
No pattern loaded
</p>
)}
</div>
</div>
{resumeAvailable && resumeFileName && (
<div className="bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800 px-3 py-2 rounded mb-3">
<p className="text-xs text-success-800 dark:text-success-200">
<strong>Cached:</strong> "{resumeFileName}"
</p>
</div>
)}
{isLoading && <PatternInfoSkeleton />}
{!isLoading && pesData && (
<div className="mb-3">
<PatternInfo pesData={pesData} showThreadBlocks />
</div>
)}
<div className="flex gap-2 mb-3">
<input
type="file"
accept=".pes"
onChange={handleFileChange}
id="file-input"
className="hidden"
disabled={
isLoading ||
patternUploaded ||
isUploading ||
(uploadProgress > 0 && !patternUploaded)
}
/>
<Button
asChild={
!fileService.hasNativeDialogs() &&
!(
isLoading ||
patternUploaded ||
isUploading ||
(uploadProgress > 0 && !patternUploaded)
)
}
onClick={
fileService.hasNativeDialogs()
? () => handleFileChange()
: undefined
}
disabled={
isLoading ||
patternUploaded ||
isUploading ||
(uploadProgress > 0 && !patternUploaded)
}
variant="outline"
className="flex-[2]"
>
{fileService.hasNativeDialogs() ? (
<>
{isLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Loading...</span>
</>
) : patternUploaded ? (
<>
<CheckCircleIcon className="w-3.5 h-3.5" />
<span>Locked</span>
</>
) : (
<>
<FolderOpenIcon className="w-3.5 h-3.5" />
<span>Choose PES File</span>
</>
)}
</>
) : (
<label
htmlFor="file-input"
className="flex items-center gap-2 cursor-pointer"
>
{isLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Loading...</span>
</>
) : patternUploaded ? (
<>
<CheckCircleIcon className="w-3.5 h-3.5" />
<span>Locked</span>
</>
) : (
<>
<FolderOpenIcon className="w-3.5 h-3.5" />
<span>Choose PES File</span>
</>
)}
</label>
)}
</Button>
{pesData &&
canUploadPattern(machineStatus) &&
!patternUploaded &&
uploadProgress < 100 && (
<Button
onClick={handleUpload}
disabled={!isConnected || isUploading || !boundsCheck.fits}
className="flex-1"
aria-label={
isUploading
? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
: boundsCheck.error || "Upload pattern to machine"
}
>
{isUploading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{uploadProgress > 0
? uploadProgress.toFixed(0) + "%"
: "Uploading"}
</>
) : (
<>
<ArrowUpTrayIcon className="w-3.5 h-3.5" />
Upload
</>
)}
</Button>
)}
</div>
{/* Pyodide initialization progress indicator - shown when initializing or waiting */}
{!pyodideReady && pyodideProgress > 0 && (
<div className="mb-3">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
{isLoading && !pyodideReady
? "Please wait - initializing Python environment..."
: pyodideLoadingStep || "Initializing Python environment..."}
</span>
<span className="text-xs font-bold text-primary-600 dark:text-primary-400">
{pyodideProgress.toFixed(0)}%
</span>
</div>
<Progress value={pyodideProgress} className="h-2.5" />
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
{isLoading && !pyodideReady
? "File dialog will open automatically when ready"
: "This only happens once on first use"}
</p>
</div>
)}
{/* Error/warning messages with smooth transition - placed after buttons */}
<div
className="transition-all duration-200 ease-in-out overflow-hidden"
style={{
maxHeight:
pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
? "200px"
: "0px",
marginTop:
pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
? "12px"
: "0px",
}}
>
{pesData && !canUploadPattern(machineStatus) && (
<Alert className="bg-warning-100 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
<AlertDescription className="text-warning-800 dark:text-warning-200 text-sm">
Cannot upload while {getMachineStateCategory(machineStatus)}
</AlertDescription>
</Alert>
)}
{pesData && boundsCheck.error && (
<Alert variant="destructive">
<AlertDescription>
<strong>Pattern too large:</strong> {boundsCheck.error}
</AlertDescription>
</Alert>
)}
</div>
{isUploading && uploadProgress < 100 && (
<div className="mt-3">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
Uploading
</span>
<span className="text-xs font-bold text-secondary-600 dark:text-secondary-400">
{uploadProgress > 0
? uploadProgress.toFixed(1) + "%"
: "Starting..."}
</span>
</div>
<Progress
value={uploadProgress}
className="h-2.5 [&>div]:bg-gradient-to-r [&>div]:from-secondary-500 [&>div]:via-secondary-600 [&>div]:to-secondary-700 dark:[&>div]:from-secondary-600 dark:[&>div]:via-secondary-700 dark:[&>div]:to-secondary-800"
/>
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,53 @@
/**
* BoundsValidator Component
*
* Renders error/warning messages with smooth transitions
*/
import { Alert, AlertDescription } from "@/components/ui/alert";
import { MachineStatus } from "../../types/machine";
import {
canUploadPattern,
getMachineStateCategory,
} from "../../utils/machineStateHelpers";
import type { PesPatternData } from "../../formats/import/pesImporter";
interface BoundsValidatorProps {
pesData: PesPatternData | null;
machineStatus: MachineStatus;
boundsError: string | null;
}
export function BoundsValidator({
pesData,
machineStatus,
boundsError,
}: BoundsValidatorProps) {
const hasError = pesData && (boundsError || !canUploadPattern(machineStatus));
return (
<div
className="transition-all duration-200 ease-in-out overflow-hidden"
style={{
maxHeight: hasError ? "200px" : "0px",
marginTop: hasError ? "12px" : "0px",
}}
>
{pesData && !canUploadPattern(machineStatus) && (
<Alert className="bg-warning-100 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800">
<AlertDescription className="text-warning-800 dark:text-warning-200 text-sm">
Cannot upload while {getMachineStateCategory(machineStatus)}
</AlertDescription>
</Alert>
)}
{pesData && boundsError && (
<Alert variant="destructive">
<AlertDescription>
<strong>Pattern too large:</strong> {boundsError}
</AlertDescription>
</Alert>
)}
</div>
);
}

View file

@ -0,0 +1,92 @@
/**
* FileSelector Component
*
* Renders file input and selection button, handles native vs web file selection
*/
import { FolderOpenIcon, CheckCircleIcon } from "@heroicons/react/24/solid";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { IFileService } from "../../platform/interfaces/IFileService";
interface FileSelectorProps {
fileService: IFileService;
isLoading: boolean;
isDisabled: boolean;
onFileChange: (event?: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
displayFileName: string;
patternUploaded: boolean;
}
export function FileSelector({
fileService,
isLoading,
isDisabled,
onFileChange,
patternUploaded,
}: FileSelectorProps) {
const hasNativeDialogs = fileService.hasNativeDialogs();
return (
<>
<input
type="file"
accept=".pes"
onChange={onFileChange}
id="file-input"
className="hidden"
disabled={isDisabled}
/>
<Button
asChild={!hasNativeDialogs && !isDisabled}
onClick={hasNativeDialogs ? () => onFileChange() : undefined}
disabled={isDisabled}
variant="outline"
className="flex-[2]"
>
{hasNativeDialogs ? (
<>
{isLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Loading...</span>
</>
) : patternUploaded ? (
<>
<CheckCircleIcon className="w-3.5 h-3.5" />
<span>Locked</span>
</>
) : (
<>
<FolderOpenIcon className="w-3.5 h-3.5" />
<span>Choose PES File</span>
</>
)}
</>
) : (
<label
htmlFor="file-input"
className="flex items-center gap-2 cursor-pointer"
>
{isLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Loading...</span>
</>
) : patternUploaded ? (
<>
<CheckCircleIcon className="w-3.5 h-3.5" />
<span>Locked</span>
</>
) : (
<>
<FolderOpenIcon className="w-3.5 h-3.5" />
<span>Choose PES File</span>
</>
)}
</label>
)}
</Button>
</>
);
}

View file

@ -0,0 +1,259 @@
/**
* FileUpload Component
*
* Orchestrates file upload UI with file selection, Pyodide initialization, pattern upload, and validation
*/
import { useState, useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import {
useMachineStore,
usePatternUploaded,
} from "../../stores/useMachineStore";
import { useMachineUploadStore } from "../../stores/useMachineUploadStore";
import { useMachineCacheStore } from "../../stores/useMachineCacheStore";
import { usePatternStore } from "../../stores/usePatternStore";
import { useUIStore } from "../../stores/useUIStore";
import type { PesPatternData } from "../../formats/import/pesImporter";
import {
useFileUpload,
usePatternRotationUpload,
usePatternValidation,
} from "@/hooks";
import { getDisplayFilename } from "../../utils/displayFilename";
import { PatternInfoSkeleton } from "../SkeletonLoader";
import { PatternInfo } from "../PatternInfo";
import { DocumentTextIcon } from "@heroicons/react/24/solid";
import { createFileService } from "../../platform";
import type { IFileService } from "../../platform/interfaces/IFileService";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { FileSelector } from "./FileSelector";
import { PyodideProgress } from "./PyodideProgress";
import { UploadButton } from "./UploadButton";
import { UploadProgress } from "./UploadProgress";
import { BoundsValidator } from "./BoundsValidator";
export function FileUpload() {
// Machine store
const { isConnected, machineStatus, machineInfo } = useMachineStore(
useShallow((state) => ({
isConnected: state.isConnected,
machineStatus: state.machineStatus,
machineInfo: state.machineInfo,
})),
);
// Machine upload store
const { uploadProgress, isUploading, uploadPattern } = useMachineUploadStore(
useShallow((state) => ({
uploadProgress: state.uploadProgress,
isUploading: state.isUploading,
uploadPattern: state.uploadPattern,
})),
);
// Machine cache store
const { resumeAvailable, resumeFileName } = useMachineCacheStore(
useShallow((state) => ({
resumeAvailable: state.resumeAvailable,
resumeFileName: state.resumeFileName,
})),
);
// Pattern store
const {
pesData: pesDataProp,
currentFileName,
patternOffset,
patternRotation,
setPattern,
setUploadedPattern,
} = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
currentFileName: state.currentFileName,
patternOffset: state.patternOffset,
patternRotation: state.patternRotation,
setPattern: state.setPattern,
setUploadedPattern: state.setUploadedPattern,
})),
);
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
// UI store
const {
pyodideReady,
pyodideProgress,
pyodideLoadingStep,
initializePyodide,
} = useUIStore(
useShallow((state) => ({
pyodideReady: state.pyodideReady,
pyodideProgress: state.pyodideProgress,
pyodideLoadingStep: state.pyodideLoadingStep,
initializePyodide: state.initializePyodide,
})),
);
const [localPesData, setLocalPesData] = useState<PesPatternData | null>(null);
const [fileName, setFileName] = useState<string>("");
const [fileService] = useState<IFileService>(() => createFileService());
// Use prop pesData if available (from cached pattern), otherwise use local state
const pesData = pesDataProp || localPesData;
// Use currentFileName from App state, or local fileName, or resumeFileName for display
const displayFileName = getDisplayFilename({
currentFileName,
localFileName: fileName,
resumeFileName,
});
// File upload hook - handles file selection and conversion
const { isLoading, handleFileChange } = useFileUpload({
fileService,
pyodideReady,
initializePyodide,
onFileLoaded: useCallback(
(data: PesPatternData, name: string) => {
setLocalPesData(data);
setFileName(name);
setPattern(data, name);
},
[setPattern],
),
});
// Pattern rotation and upload hook - handles rotation transformation
const { handleUpload: handlePatternUpload } = usePatternRotationUpload({
uploadPattern,
setUploadedPattern,
});
// Wrapper to call upload with current pattern data
const handleUpload = useCallback(async () => {
if (pesData && displayFileName) {
await handlePatternUpload(
pesData,
displayFileName,
patternOffset,
patternRotation,
);
}
}, [
pesData,
displayFileName,
patternOffset,
patternRotation,
handlePatternUpload,
]);
// Pattern validation hook - checks if pattern fits in hoop
const boundsCheck = usePatternValidation({
pesData,
machineInfo,
patternOffset,
patternRotation,
});
const borderColor = pesData
? "border-secondary-600 dark:border-secondary-500"
: "border-gray-400 dark:border-gray-600";
const iconColor = pesData
? "text-secondary-600 dark:text-secondary-400"
: "text-gray-600 dark:text-gray-400";
const isSelectorDisabled =
isLoading ||
patternUploaded ||
isUploading ||
(uploadProgress > 0 && !patternUploaded);
return (
<Card className={cn("p-0 gap-0 border-l-4", borderColor)}>
<CardContent className="p-4 rounded-lg">
<div className="flex items-start gap-3 mb-3">
<DocumentTextIcon
className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)}
/>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
Pattern File
</h3>
{pesData && displayFileName ? (
<p
className="text-xs text-gray-600 dark:text-gray-400 truncate"
title={displayFileName}
>
{displayFileName}
</p>
) : (
<p className="text-xs text-gray-600 dark:text-gray-400">
No pattern loaded
</p>
)}
</div>
</div>
{resumeAvailable && resumeFileName && (
<div className="bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800 px-3 py-2 rounded mb-3">
<p className="text-xs text-success-800 dark:text-success-200">
<strong>Cached:</strong> "{resumeFileName}"
</p>
</div>
)}
{isLoading && <PatternInfoSkeleton />}
{!isLoading && pesData && (
<div className="mb-3">
<PatternInfo pesData={pesData} showThreadBlocks />
</div>
)}
<div className="flex gap-2 mb-3">
<FileSelector
fileService={fileService}
isLoading={isLoading}
isDisabled={isSelectorDisabled}
onFileChange={handleFileChange}
displayFileName={displayFileName}
patternUploaded={patternUploaded}
/>
<UploadButton
pesData={pesData}
machineStatus={machineStatus}
isConnected={isConnected}
isUploading={isUploading}
uploadProgress={uploadProgress}
boundsFits={boundsCheck.fits}
boundsError={boundsCheck.error}
onUpload={handleUpload}
patternUploaded={patternUploaded}
/>
</div>
<PyodideProgress
pyodideReady={pyodideReady}
pyodideProgress={pyodideProgress}
pyodideLoadingStep={pyodideLoadingStep}
isFileLoading={isLoading}
/>
<BoundsValidator
pesData={pesData}
machineStatus={machineStatus}
boundsError={boundsCheck.error}
/>
<UploadProgress
isUploading={isUploading}
uploadProgress={uploadProgress}
/>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,44 @@
/**
* PyodideProgress Component
*
* Renders Pyodide initialization progress indicator
*/
import { Progress } from "@/components/ui/progress";
interface PyodideProgressProps {
pyodideReady: boolean;
pyodideProgress: number;
pyodideLoadingStep: string | null;
isFileLoading: boolean;
}
export function PyodideProgress({
pyodideReady,
pyodideProgress,
pyodideLoadingStep,
isFileLoading,
}: PyodideProgressProps) {
if (pyodideReady || pyodideProgress === 0) return null;
return (
<div className="mb-3">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
{isFileLoading
? "Please wait - initializing Python environment..."
: pyodideLoadingStep || "Initializing Python environment..."}
</span>
<span className="text-xs font-bold text-primary-600 dark:text-primary-400">
{pyodideProgress.toFixed(0)}%
</span>
</div>
<Progress value={pyodideProgress} className="h-2.5" />
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
{isFileLoading
? "File dialog will open automatically when ready"
: "This only happens once on first use"}
</p>
</div>
);
}

View file

@ -0,0 +1,69 @@
/**
* UploadButton Component
*
* Renders upload button with progress, conditionally shown based on machine state
*/
import { ArrowUpTrayIcon } from "@heroicons/react/24/solid";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { MachineStatus } from "../../types/machine";
import { canUploadPattern } from "../../utils/machineStateHelpers";
import type { PesPatternData } from "../../formats/import/pesImporter";
interface UploadButtonProps {
pesData: PesPatternData | null;
machineStatus: MachineStatus;
isConnected: boolean;
isUploading: boolean;
uploadProgress: number;
boundsFits: boolean;
boundsError: string | null;
onUpload: () => Promise<void>;
patternUploaded: boolean;
}
export function UploadButton({
pesData,
machineStatus,
isConnected,
isUploading,
uploadProgress,
boundsFits,
boundsError,
onUpload,
patternUploaded,
}: UploadButtonProps) {
const shouldShow =
pesData &&
canUploadPattern(machineStatus) &&
!patternUploaded &&
uploadProgress < 100;
if (!shouldShow) return null;
return (
<Button
onClick={onUpload}
disabled={!isConnected || isUploading || !boundsFits}
className="flex-1"
aria-label={
isUploading
? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
: boundsError || "Upload pattern to machine"
}
>
{isUploading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{uploadProgress > 0 ? uploadProgress.toFixed(0) + "%" : "Uploading"}
</>
) : (
<>
<ArrowUpTrayIcon className="w-3.5 h-3.5" />
Upload
</>
)}
</Button>
);
}

View file

@ -0,0 +1,36 @@
/**
* UploadProgress Component
*
* Renders upload progress bar
*/
import { Progress } from "@/components/ui/progress";
interface UploadProgressProps {
isUploading: boolean;
uploadProgress: number;
}
export function UploadProgress({
isUploading,
uploadProgress,
}: UploadProgressProps) {
if (!isUploading || uploadProgress >= 100) return null;
return (
<div className="mt-3">
<div className="flex justify-between items-center mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
Uploading
</span>
<span className="text-xs font-bold text-secondary-600 dark:text-secondary-400">
{uploadProgress > 0 ? uploadProgress.toFixed(1) + "%" : "Starting..."}
</span>
</div>
<Progress
value={uploadProgress}
className="h-2.5 [&>div]:bg-gradient-to-r [&>div]:from-secondary-500 [&>div]:via-secondary-600 [&>div]:to-secondary-700 dark:[&>div]:from-secondary-600 dark:[&>div]:via-secondary-700 dark:[&>div]:to-secondary-800"
/>
</div>
);
}

View file

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

View file

@ -1,4 +1,4 @@
import { useRef } from "react";
import { useRef, useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import {
useMachineStore,
@ -21,8 +21,7 @@ import { ThreadLegend } from "./ThreadLegend";
import { PatternPositionIndicator } from "./PatternPositionIndicator";
import { ZoomControls } from "./ZoomControls";
import { PatternLayer } from "./PatternLayer";
import { useCanvasViewport } from "../../hooks/useCanvasViewport";
import { usePatternTransform } from "../../hooks/usePatternTransform";
import { useCanvasViewport, usePatternTransform } from "@/hooks";
export function PatternCanvas() {
// Machine store
@ -110,6 +109,26 @@ export function PatternCanvas() {
? "text-tertiary-600 dark:text-tertiary-400"
: "text-gray-600 dark:text-gray-400";
// Memoize the display pattern to avoid recalculation
const displayPattern = useMemo(
() => uploadedPesData || pesData,
[uploadedPesData, pesData],
);
// Memoize pattern dimensions calculation
const patternDimensions = useMemo(() => {
if (!displayPattern) return null;
const width = (
(displayPattern.bounds.maxX - displayPattern.bounds.minX) /
10
).toFixed(1);
const height = (
(displayPattern.bounds.maxY - displayPattern.bounds.minY) /
10
).toFixed(1);
return `${width} × ${height} mm`;
}, [displayPattern]);
return (
<Card
className={`p-0 gap-0 lg:h-full flex flex-col border-l-4 ${borderColor}`}
@ -121,25 +140,7 @@ export function PatternCanvas() {
<CardTitle className="text-sm">Pattern Preview</CardTitle>
{hasPattern ? (
<CardDescription className="text-xs">
{(() => {
const displayPattern = uploadedPesData || pesData;
return displayPattern ? (
<>
{(
(displayPattern.bounds.maxX -
displayPattern.bounds.minX) /
10
).toFixed(1)}{" "}
×{" "}
{(
(displayPattern.bounds.maxY -
displayPattern.bounds.minY) /
10
).toFixed(1)}{" "}
mm
</>
) : null;
})()}
{patternDimensions}
</CardDescription>
) : (
<CardDescription className="text-xs">
@ -183,11 +184,11 @@ export function PatternCanvas() {
>
{/* Background layer: grid, origin, hoop */}
<Layer>
{hasPattern && (
{displayPattern && (
<>
<Grid
gridSize={100}
bounds={(uploadedPesData || pesData)!.bounds}
bounds={displayPattern.bounds}
machineInfo={machineInfo}
/>
<Origin />
@ -242,42 +243,36 @@ export function PatternCanvas() {
)}
{/* Pattern info overlays */}
{hasPattern &&
(() => {
const displayPattern = uploadedPesData || pesData;
return (
displayPattern && (
<>
<ThreadLegend colors={displayPattern.uniqueColors} />
{displayPattern && (
<>
<ThreadLegend colors={displayPattern.uniqueColors} />
<PatternPositionIndicator
offset={
isUploading || patternUploaded || uploadedPesData
? initialUploadedPatternOffset
: localPatternOffset
}
rotation={localPatternRotation}
isLocked={patternUploaded || !!uploadedPesData}
isUploading={isUploading}
/>
<PatternPositionIndicator
offset={
isUploading || patternUploaded || uploadedPesData
? initialUploadedPatternOffset
: localPatternOffset
}
rotation={localPatternRotation}
isLocked={patternUploaded || !!uploadedPesData}
isUploading={isUploading}
/>
<ZoomControls
scale={stageScale}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onZoomReset={handleZoomReset}
onCenterPattern={handleCenterPattern}
canCenterPattern={
!!pesData &&
!patternUploaded &&
!isUploading &&
!uploadedPesData
}
/>
</>
)
);
})()}
<ZoomControls
scale={stageScale}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onZoomReset={handleZoomReset}
onCenterPattern={handleCenterPattern}
canCenterPattern={
!!pesData &&
!patternUploaded &&
!isUploading &&
!uploadedPesData
}
/>
</>
)}
</div>
</CardContent>
</Card>

View file

@ -5,7 +5,7 @@
* Handles both interactive (draggable/rotatable) and locked states
*/
import { useMemo, type RefObject } from "react";
import { useMemo, memo, type RefObject } from "react";
import { Group, Transformer } from "react-konva";
import type Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node";
@ -30,7 +30,7 @@ interface PatternLayerProps {
attachTransformer?: () => void;
}
export function PatternLayer({
export const PatternLayer = memo(function PatternLayer({
pesData,
offset,
rotation = 0,
@ -143,4 +143,4 @@ export function PatternLayer({
)}
</>
);
}
});

View file

@ -1,395 +0,0 @@
import { useRef, useEffect, useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore";
import { usePatternStore } from "../stores/usePatternStore";
import {
CheckCircleIcon,
ArrowRightIcon,
CircleStackIcon,
PlayIcon,
ChartBarIcon,
ArrowPathIcon,
} from "@heroicons/react/24/solid";
import { MachineStatus } from "../types/machine";
import {
canStartSewing,
canStartMaskTrace,
canResumeSewing,
} from "../utils/machineStateHelpers";
import { calculatePatternTime } from "../utils/timeCalculation";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
export function ProgressMonitor() {
// Machine store
const {
machineStatus,
patternInfo,
sewingProgress,
isDeleting,
startMaskTrace,
startSewing,
resumeSewing,
} = useMachineStore(
useShallow((state) => ({
machineStatus: state.machineStatus,
patternInfo: state.patternInfo,
sewingProgress: state.sewingProgress,
isDeleting: state.isDeleting,
startMaskTrace: state.startMaskTrace,
startSewing: state.startSewing,
resumeSewing: state.resumeSewing,
})),
);
// Pattern store
const pesData = usePatternStore((state) => state.pesData);
const uploadedPesData = usePatternStore((state) => state.uploadedPesData);
const displayPattern = uploadedPesData || pesData;
const currentBlockRef = useRef<HTMLDivElement>(null);
// State indicators
const isMaskTraceComplete =
machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
// Use PEN stitch count as fallback when machine reports 0 total stitches
const totalStitches = patternInfo
? patternInfo.totalStitches === 0 && displayPattern?.penStitches
? displayPattern.penStitches.stitches.length
: patternInfo.totalStitches
: 0;
const progressPercent =
totalStitches > 0
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
: 0;
// Calculate color block information from decoded penStitches
const colorBlocks = useMemo(() => {
if (!displayPattern || !displayPattern.penStitches) return [];
const blocks: Array<{
colorIndex: number;
threadHex: string;
startStitch: number;
endStitch: number;
stitchCount: number;
threadCatalogNumber: string | null;
threadBrand: string | null;
threadDescription: string | null;
threadChart: string | null;
}> = [];
// Use the pre-computed color blocks from decoded PEN data
for (const penBlock of displayPattern.penStitches.colorBlocks) {
const thread = displayPattern.threads[penBlock.colorIndex];
blocks.push({
colorIndex: penBlock.colorIndex,
threadHex: thread?.hex || "#000000",
threadCatalogNumber: thread?.catalogNumber ?? null,
threadBrand: thread?.brand ?? null,
threadDescription: thread?.description ?? null,
threadChart: thread?.chart ?? null,
startStitch: penBlock.startStitchIndex,
endStitch: penBlock.endStitchIndex,
stitchCount: penBlock.endStitchIndex - penBlock.startStitchIndex,
});
}
return blocks;
}, [displayPattern]);
// Determine current color block based on current stitch
const currentStitch = sewingProgress?.currentStitch || 0;
const currentBlockIndex = colorBlocks.findIndex(
(block) =>
currentStitch >= block.startStitch && currentStitch < block.endStitch,
);
// Calculate time based on color blocks (matches Brother app calculation)
const { totalMinutes, elapsedMinutes } = useMemo(() => {
if (colorBlocks.length === 0) {
return { totalMinutes: 0, elapsedMinutes: 0 };
}
const result = calculatePatternTime(colorBlocks, currentStitch);
return {
totalMinutes: result.totalMinutes,
elapsedMinutes: result.elapsedMinutes,
};
}, [colorBlocks, currentStitch]);
// Auto-scroll to current block
useEffect(() => {
if (currentBlockRef.current) {
currentBlockRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [currentBlockIndex]);
return (
<Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<ChartBarIcon className="w-6 h-6 text-accent-600 dark:text-accent-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm">Sewing Progress</CardTitle>
{sewingProgress && (
<CardDescription className="text-xs">
{progressPercent.toFixed(1)}% complete
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col lg:overflow-hidden">
{/* Pattern Info */}
{patternInfo && (
<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">
<span className="text-gray-600 dark:text-gray-400 block">
Total Stitches
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{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">
Total Time
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{totalMinutes} min
</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">
Speed
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{patternInfo.speed} spm
</span>
</div>
</div>
)}
{/* Progress Bar */}
{sewingProgress && (
<div className="mb-3">
<Progress
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"
/>
<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">
{sewingProgress.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>
)}
{/* Color Blocks */}
{colorBlocks.length > 0 && (
<div className="mb-3 lg:flex-1 lg:min-h-0 flex flex-col">
<h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300 flex-shrink-0">
Color Blocks
</h4>
<ScrollArea className="lg:flex-1 lg:h-0">
<div className="flex flex-col gap-2 pr-4">
{colorBlocks.map((block, index) => {
const isCompleted = currentStitch >= block.endStitch;
const isCurrent = index === currentBlockIndex;
// Calculate progress within current block
let blockProgress = 0;
if (isCurrent) {
blockProgress =
((currentStitch - block.startStitch) /
block.stitchCount) *
100;
} else if (isCompleted) {
blockProgress = 100;
}
return (
<div
key={index}
ref={isCurrent ? currentBlockRef : null}
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
isCompleted
? "border-success-600 bg-success-50 dark:bg-success-900/20"
: isCurrent
? "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-700"
: "border-gray-200 dark:border-gray-600 bg-gray-100 dark:bg-gray-800/50 opacity-70"
}`}
role="listitem"
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
>
<div className="flex items-center gap-2.5">
{/* Color swatch */}
<div
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
style={{
backgroundColor: block.threadHex,
}}
title={`Thread color: ${block.threadHex}`}
aria-label={`Thread color ${block.threadHex}`}
/>
{/* Thread info */}
<div className="flex-1 min-w-0">
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
Thread {block.colorIndex + 1}
{(block.threadBrand ||
block.threadChart ||
block.threadDescription ||
block.threadCatalogNumber) && (
<span className="font-normal text-gray-600 dark:text-gray-400">
{" "}
(
{(() => {
// Primary metadata: brand and catalog number
const primaryMetadata = [
block.threadBrand,
block.threadCatalogNumber
? `#${block.threadCatalogNumber}`
: null,
]
.filter(Boolean)
.join(" ");
// Secondary metadata: chart and description
// Only show chart if it's different from catalogNumber
const secondaryMetadata = [
block.threadChart &&
block.threadChart !==
block.threadCatalogNumber
? block.threadChart
: null,
block.threadDescription,
]
.filter(Boolean)
.join(" ");
return [primaryMetadata, secondaryMetadata]
.filter(Boolean)
.join(" • ");
})()}
)
</span>
)}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
{block.stitchCount.toLocaleString()} stitches
</div>
</div>
{/* Status icon */}
{isCompleted ? (
<CheckCircleIcon
className="w-5 h-5 text-success-600 flex-shrink-0"
aria-label="Completed"
/>
) : isCurrent ? (
<ArrowRightIcon
className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 animate-pulse"
aria-label="In progress"
/>
) : (
<CircleStackIcon
className="w-5 h-5 text-gray-400 flex-shrink-0"
aria-label="Pending"
/>
)}
</div>
{/* Progress bar for current block */}
{isCurrent && (
<Progress
value={blockProgress}
className="mt-2 h-1.5 [&>div]:bg-gray-600 dark:[&>div]:bg-gray-500"
aria-label={`${Math.round(blockProgress)}% complete`}
/>
)}
</div>
);
})}
</div>
</ScrollArea>
</div>
)}
{/* Action buttons */}
<div className="flex gap-2 flex-shrink-0">
{/* Resume has highest priority when available */}
{canResumeSewing(machineStatus) && (
<Button
onClick={resumeSewing}
disabled={isDeleting}
className="flex-1"
aria-label="Resume sewing the current pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Resume Sewing
</Button>
)}
{/* Start Sewing - primary action, takes more space */}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
<Button
onClick={startSewing}
disabled={isDeleting}
className="flex-[2]"
aria-label="Start sewing the pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Start Sewing
</Button>
)}
{/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && (
<Button
onClick={startMaskTrace}
disabled={isDeleting}
variant="outline"
className="flex-1"
aria-label={
isMaskTraceComplete
? "Start mask trace again"
: "Start mask trace"
}
>
<ArrowPathIcon className="w-3.5 h-3.5" />
{isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,115 @@
/**
* ColorBlockItem Component
*
* Renders an individual color block card with thread metadata, stitch count, status icon, and progress
*/
import { forwardRef } from "react";
import {
CheckCircleIcon,
ArrowRightIcon,
CircleStackIcon,
} from "@heroicons/react/24/solid";
import { Progress } from "@/components/ui/progress";
import { formatThreadMetadata } from "../../utils/threadMetadata";
import type { ColorBlock } from "./types";
interface ColorBlockItemProps {
block: ColorBlock;
index: number;
currentStitch: number;
isCurrent: boolean;
isCompleted: boolean;
}
export const ColorBlockItem = forwardRef<HTMLDivElement, ColorBlockItemProps>(
({ block, index, currentStitch, isCurrent, isCompleted }, ref) => {
// Calculate progress within current block
let blockProgress = 0;
if (isCurrent) {
blockProgress =
((currentStitch - block.startStitch) / block.stitchCount) * 100;
} else if (isCompleted) {
blockProgress = 100;
}
const hasMetadata =
block.threadBrand ||
block.threadChart ||
block.threadDescription ||
block.threadCatalogNumber;
return (
<div
key={index}
ref={isCurrent ? ref : null}
className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
isCompleted
? "border-success-600 bg-success-50 dark:bg-success-900/20"
: isCurrent
? "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-700"
: "border-gray-200 dark:border-gray-600 bg-gray-100 dark:bg-gray-800/50 opacity-70"
}`}
role="listitem"
aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
>
<div className="flex items-center gap-2.5">
{/* Color swatch */}
<div
className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
style={{
backgroundColor: block.threadHex,
}}
title={`Thread color: ${block.threadHex}`}
aria-label={`Thread color ${block.threadHex}`}
/>
{/* Thread info */}
<div className="flex-1 min-w-0">
<div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
Thread {block.colorIndex + 1}
{hasMetadata && (
<span className="font-normal text-gray-600 dark:text-gray-400">
{" "}
({formatThreadMetadata(block)})
</span>
)}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
{block.stitchCount.toLocaleString()} stitches
</div>
</div>
{/* Status icon */}
{isCompleted ? (
<CheckCircleIcon
className="w-5 h-5 text-success-600 flex-shrink-0"
aria-label="Completed"
/>
) : isCurrent ? (
<ArrowRightIcon
className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 animate-pulse"
aria-label="In progress"
/>
) : (
<CircleStackIcon
className="w-5 h-5 text-gray-400 flex-shrink-0"
aria-label="Pending"
/>
)}
</div>
{/* Progress bar for current block */}
{isCurrent && (
<Progress
value={blockProgress}
className="mt-2 h-1.5 [&>div]:bg-gray-600 dark:[&>div]:bg-gray-500"
aria-label={`${Math.round(blockProgress)}% complete`}
/>
)}
</div>
);
},
);
ColorBlockItem.displayName = "ColorBlockItem";

View file

@ -0,0 +1,53 @@
/**
* ColorBlockList Component
*
* Container for the scrollable list of color blocks
*/
import { ScrollArea } from "@/components/ui/scroll-area";
import { ColorBlockItem } from "./ColorBlockItem";
import type { ColorBlock } from "./types";
interface ColorBlockListProps {
colorBlocks: ColorBlock[];
currentStitch: number;
currentBlockIndex: number;
currentBlockRef: React.RefObject<HTMLDivElement | null>;
}
export function ColorBlockList({
colorBlocks,
currentStitch,
currentBlockIndex,
currentBlockRef,
}: ColorBlockListProps) {
if (colorBlocks.length === 0) return null;
return (
<div className="mb-3 lg:flex-1 lg:min-h-0 flex flex-col">
<h4 className="text-xs font-semibold mb-2 text-gray-700 dark:text-gray-300 flex-shrink-0">
Color Blocks
</h4>
<ScrollArea className="lg:flex-1 lg:h-0">
<div className="flex flex-col gap-2 pr-4">
{colorBlocks.map((block, index) => {
const isCompleted = currentStitch >= block.endStitch;
const isCurrent = index === currentBlockIndex;
return (
<ColorBlockItem
key={index}
ref={isCurrent ? currentBlockRef : null}
block={block}
index={index}
currentStitch={currentStitch}
isCurrent={isCurrent}
isCompleted={isCompleted}
/>
);
})}
</div>
</ScrollArea>
</div>
);
}

View file

@ -0,0 +1,78 @@
/**
* ProgressActions Component
*
* Renders action buttons (Resume Sewing, Start Sewing, Start Mask Trace)
*/
import { PlayIcon, ArrowPathIcon } from "@heroicons/react/24/solid";
import { Button } from "@/components/ui/button";
import { MachineStatus } from "../../types/machine";
import {
canStartSewing,
canStartMaskTrace,
canResumeSewing,
} from "../../utils/machineStateHelpers";
interface ProgressActionsProps {
machineStatus: MachineStatus;
isDeleting: boolean;
isMaskTraceComplete: boolean;
onResumeSewing: () => void;
onStartSewing: () => void;
onStartMaskTrace: () => void;
}
export function ProgressActions({
machineStatus,
isDeleting,
isMaskTraceComplete,
onResumeSewing,
onStartSewing,
onStartMaskTrace,
}: ProgressActionsProps) {
return (
<div className="flex gap-2 flex-shrink-0">
{/* Resume has highest priority when available */}
{canResumeSewing(machineStatus) && (
<Button
onClick={onResumeSewing}
disabled={isDeleting}
className="flex-1"
aria-label="Resume sewing the current pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Resume Sewing
</Button>
)}
{/* Start Sewing - primary action, takes more space */}
{canStartSewing(machineStatus) && !canResumeSewing(machineStatus) && (
<Button
onClick={onStartSewing}
disabled={isDeleting}
className="flex-[2]"
aria-label="Start sewing the pattern"
>
<PlayIcon className="w-3.5 h-3.5" />
Start Sewing
</Button>
)}
{/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && (
<Button
onClick={onStartMaskTrace}
disabled={isDeleting}
variant="outline"
className="flex-1"
aria-label={
isMaskTraceComplete ? "Start mask trace again" : "Start mask trace"
}
>
<ArrowPathIcon className="w-3.5 h-3.5" />
{isMaskTraceComplete ? "Trace Again" : "Start Mask Trace"}
</Button>
)}
</div>
);
}

View file

@ -0,0 +1,155 @@
/**
* ProgressMonitor Component
*
* Orchestrates progress monitoring UI with stats, progress bar, color blocks, and action buttons
*/
import { useMemo } from "react";
import { useAutoScroll } from "@/hooks";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../../stores/useMachineStore";
import { usePatternStore } from "../../stores/usePatternStore";
import { ChartBarIcon } from "@heroicons/react/24/solid";
import { MachineStatus } from "../../types/machine";
import { calculatePatternTime } from "../../utils/timeCalculation";
import {
calculateColorBlocks,
findCurrentBlockIndex,
} from "../../utils/colorBlockHelpers";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { ProgressStats } from "./ProgressStats";
import { ProgressSection } from "./ProgressSection";
import { ColorBlockList } from "./ColorBlockList";
import { ProgressActions } from "./ProgressActions";
export function ProgressMonitor() {
// Machine store
const {
machineStatus,
patternInfo,
sewingProgress,
isDeleting,
startMaskTrace,
startSewing,
resumeSewing,
} = useMachineStore(
useShallow((state) => ({
machineStatus: state.machineStatus,
patternInfo: state.patternInfo,
sewingProgress: state.sewingProgress,
isDeleting: state.isDeleting,
startMaskTrace: state.startMaskTrace,
startSewing: state.startSewing,
resumeSewing: state.resumeSewing,
})),
);
// Pattern store
const pesData = usePatternStore((state) => state.pesData);
const uploadedPesData = usePatternStore((state) => state.uploadedPesData);
const displayPattern = uploadedPesData || pesData;
// State indicators
const isMaskTraceComplete =
machineStatus === MachineStatus.MASK_TRACE_COMPLETE;
// Use PEN stitch count as fallback when machine reports 0 total stitches
const totalStitches = patternInfo
? patternInfo.totalStitches === 0 && displayPattern?.penStitches
? displayPattern.penStitches.stitches.length
: patternInfo.totalStitches
: 0;
const progressPercent =
totalStitches > 0
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
: 0;
// Calculate color block information from decoded penStitches
const colorBlocks = useMemo(
() => calculateColorBlocks(displayPattern),
[displayPattern],
);
// Determine current color block based on current stitch
const currentStitch = sewingProgress?.currentStitch || 0;
const currentBlockIndex = findCurrentBlockIndex(colorBlocks, currentStitch);
// Calculate time based on color blocks (matches Brother app calculation)
const { totalMinutes, elapsedMinutes } = useMemo(() => {
if (colorBlocks.length === 0) {
return { totalMinutes: 0, elapsedMinutes: 0 };
}
const result = calculatePatternTime(colorBlocks, currentStitch);
return {
totalMinutes: result.totalMinutes,
elapsedMinutes: result.elapsedMinutes,
};
}, [colorBlocks, currentStitch]);
// Auto-scroll to current block
const currentBlockRef = useAutoScroll<HTMLDivElement>(currentBlockIndex);
return (
<Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<ChartBarIcon className="w-6 h-6 text-accent-600 dark:text-accent-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm">Sewing Progress</CardTitle>
{sewingProgress && (
<CardDescription className="text-xs">
{progressPercent.toFixed(1)}% complete
</CardDescription>
)}
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col lg:overflow-hidden">
{/* Pattern Info */}
{patternInfo && (
<ProgressStats
totalStitches={totalStitches}
totalMinutes={totalMinutes}
speed={patternInfo.speed}
/>
)}
{/* Progress Bar */}
{sewingProgress && (
<ProgressSection
currentStitch={sewingProgress.currentStitch}
totalStitches={totalStitches}
elapsedMinutes={elapsedMinutes}
totalMinutes={totalMinutes}
progressPercent={progressPercent}
/>
)}
{/* Color Blocks */}
<ColorBlockList
colorBlocks={colorBlocks}
currentStitch={currentStitch}
currentBlockIndex={currentBlockIndex}
currentBlockRef={currentBlockRef}
/>
{/* Action buttons */}
<ProgressActions
machineStatus={machineStatus}
isDeleting={isDeleting}
isMaskTraceComplete={isMaskTraceComplete}
onResumeSewing={resumeSewing}
onStartSewing={startSewing}
onStartMaskTrace={startMaskTrace}
/>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,49 @@
/**
* ProgressSection Component
*
* Displays the progress bar and current/total stitch information
*/
import { Progress } from "@/components/ui/progress";
interface ProgressSectionProps {
currentStitch: number;
totalStitches: number;
elapsedMinutes: number;
totalMinutes: number;
progressPercent: number;
}
export function ProgressSection({
currentStitch,
totalStitches,
elapsedMinutes,
totalMinutes,
progressPercent,
}: ProgressSectionProps) {
return (
<div className="mb-3">
<Progress
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"
/>
<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>
);
}

View file

@ -0,0 +1,44 @@
/**
* ProgressStats Component
*
* Displays three stat cards: total stitches, total time, and speed
*/
interface ProgressStatsProps {
totalStitches: number;
totalMinutes: number;
speed: number;
}
export function ProgressStats({
totalStitches,
totalMinutes,
speed,
}: ProgressStatsProps) {
return (
<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">
<span className="text-gray-600 dark:text-gray-400 block">
Total Stitches
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{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">
Total Time
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{totalMinutes} min
</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">Speed</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{speed} spm
</span>
</div>
</div>
);
}

View file

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

View file

@ -0,0 +1,15 @@
/**
* Shared types for ProgressMonitor components
*/
export interface ColorBlock {
colorIndex: number;
threadHex: string;
startStitch: number;
endStitch: number;
stitchCount: number;
threadCatalogNumber: string | null;
threadBrand: string | null;
threadDescription: string | null;
threadChart: string | null;
}

View file

@ -1,504 +0,0 @@
import { useState, useRef, useEffect } from "react";
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
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"
>
{/* 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
${isComplete || isCurrent ? "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,125 @@
/**
* 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) => {
// Parse **text** markdown syntax into React elements safely
const parts = item.split(/(\*\*.*?\*\*)/);
return (
<li key={index} className="pl-2">
{parts.map((part, i) => {
if (part.startsWith("**") && part.endsWith("**")) {
return <strong key={i}>{part.slice(2, -2)}</strong>;
}
return part;
})}
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
</div>
);
},
);
StepPopover.displayName = "StepPopover";

View file

@ -0,0 +1,144 @@
/**
* 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 {
readonly id: number;
readonly label: string;
readonly 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;

12
src/hooks/domain/index.ts Normal file
View file

@ -0,0 +1,12 @@
export { usePatternValidation } from "./usePatternValidation";
export { usePatternRotationUpload } from "./usePatternRotationUpload";
export { useMachinePolling } from "./useMachinePolling";
export { useErrorPopoverState } from "./useErrorPopoverState";
export type {
UseMachinePollingOptions,
UseMachinePollingReturn,
} from "./useMachinePolling";
export type {
UseErrorPopoverStateOptions,
UseErrorPopoverStateReturn,
} from "./useErrorPopoverState";

View file

@ -0,0 +1,280 @@
import { describe, it, expect } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useErrorPopoverState } from "./useErrorPopoverState";
describe("useErrorPopoverState", () => {
const hasError = (error: number | undefined) =>
error !== undefined && error !== 0;
it("should start with popover closed", () => {
const { result } = renderHook(() =>
useErrorPopoverState({
machineError: undefined,
machineErrorMessage: null,
pyodideError: null,
hasError,
}),
);
expect(result.current.isOpen).toBe(false);
expect(result.current.wasManuallyDismissed).toBe(false);
expect(result.current.dismissedErrorCode).toBeNull();
});
it("should auto-open when machine error appears", () => {
const { result, rerender } = renderHook(
({ machineError }) =>
useErrorPopoverState({
machineError,
machineErrorMessage: null,
pyodideError: null,
hasError,
}),
{ initialProps: { machineError: undefined as number | undefined } },
);
expect(result.current.isOpen).toBe(false);
// Error appears
rerender({ machineError: 1 as number | undefined });
expect(result.current.isOpen).toBe(true);
});
it("should auto-open when machine error message appears", () => {
const { result, rerender } = renderHook(
({ machineErrorMessage }) =>
useErrorPopoverState({
machineError: undefined,
machineErrorMessage,
pyodideError: null,
hasError,
}),
{ initialProps: { machineErrorMessage: null as string | null } },
);
expect(result.current.isOpen).toBe(false);
rerender({ machineErrorMessage: "Error occurred" as string | null });
expect(result.current.isOpen).toBe(true);
});
it("should auto-open when pyodide error appears", () => {
const { result, rerender } = renderHook(
({ pyodideError }) =>
useErrorPopoverState({
machineError: undefined,
machineErrorMessage: null,
pyodideError,
hasError,
}),
{ initialProps: { pyodideError: null as string | null } },
);
expect(result.current.isOpen).toBe(false);
rerender({ pyodideError: "Pyodide error" as string | null });
expect(result.current.isOpen).toBe(true);
});
it("should auto-close when all errors are cleared", () => {
const { result, rerender } = renderHook(
({ machineError }) =>
useErrorPopoverState({
machineError,
machineErrorMessage: null,
pyodideError: null,
hasError,
}),
{ initialProps: { machineError: 1 } },
);
expect(result.current.isOpen).toBe(true);
// Clear error
rerender({ machineError: 0 });
expect(result.current.isOpen).toBe(false);
expect(result.current.wasManuallyDismissed).toBe(false);
expect(result.current.dismissedErrorCode).toBeNull();
});
it("should track manual dismissal", async () => {
const { result } = renderHook(
({ machineError }) =>
useErrorPopoverState({
machineError,
machineErrorMessage: null,
pyodideError: null,
hasError,
}),
{ initialProps: { machineError: 1 } },
);
expect(result.current.isOpen).toBe(true);
// Manually dismiss
act(() => {
result.current.handleOpenChange(false);
});
await waitFor(() => {
expect(result.current.isOpen).toBe(false);
});
expect(result.current.wasManuallyDismissed).toBe(true);
expect(result.current.dismissedErrorCode).toBe(1);
});
it("should not auto-reopen after manual dismissal", async () => {
const { result, rerender } = renderHook(
({ machineError }) =>
useErrorPopoverState({
machineError,
machineErrorMessage: null,
pyodideError: null,
hasError,
}),
{ initialProps: { machineError: 1 } },
);
// Manually dismiss
act(() => {
result.current.handleOpenChange(false);
});
await waitFor(() => {
expect(result.current.wasManuallyDismissed).toBe(true);
});
// Try to reopen by changing error (but same error code)
rerender({ machineError: 1 });
expect(result.current.isOpen).toBe(false);
});
it("should auto-open for new error after manual dismissal and error clear", async () => {
const { result, rerender } = renderHook(
({ machineError }) =>
useErrorPopoverState({
machineError,
machineErrorMessage: null,
pyodideError: null,
hasError,
}),
{ initialProps: { machineError: 1 } },
);
// Manually dismiss error 1
act(() => {
result.current.handleOpenChange(false);
});
await waitFor(() => {
expect(result.current.dismissedErrorCode).toBe(1);
});
// Clear all errors first (this resets wasManuallyDismissed)
rerender({ machineError: 0 });
await waitFor(() => {
expect(result.current.wasManuallyDismissed).toBe(false);
});
// New error appears (error 2)
rerender({ machineError: 2 });
// Should auto-open since manual dismissal was reset
await waitFor(() => {
expect(result.current.isOpen).toBe(true);
});
});
it("should reset dismissal tracking when all errors clear", async () => {
const { result, rerender } = renderHook(
({ machineError }) =>
useErrorPopoverState({
machineError,
machineErrorMessage: null,
pyodideError: null,
hasError,
}),
{ initialProps: { machineError: 1 } },
);
// Manually dismiss
act(() => {
result.current.handleOpenChange(false);
});
await waitFor(() => {
expect(result.current.wasManuallyDismissed).toBe(true);
});
// Clear error
rerender({ machineError: 0 });
await waitFor(() => {
expect(result.current.wasManuallyDismissed).toBe(false);
});
expect(result.current.dismissedErrorCode).toBeNull();
});
it("should handle multiple error sources", () => {
const { result, rerender } = renderHook(
({
machineError,
machineErrorMessage,
pyodideError,
}: {
machineError: number | undefined;
machineErrorMessage: string | null;
pyodideError: string | null;
}) =>
useErrorPopoverState({
machineError,
machineErrorMessage,
pyodideError,
hasError,
}),
{
initialProps: {
machineError: undefined as number | undefined,
machineErrorMessage: null as string | null,
pyodideError: null as string | null,
},
},
);
expect(result.current.isOpen).toBe(false);
// Machine error appears
rerender({
machineError: 1 as number | undefined,
machineErrorMessage: null as string | null,
pyodideError: null as string | null,
});
expect(result.current.isOpen).toBe(true);
// Additional pyodide error
rerender({
machineError: 1 as number | undefined,
machineErrorMessage: null as string | null,
pyodideError: "Pyodide error" as string | null,
});
expect(result.current.isOpen).toBe(true);
// Clear machine error but pyodide error remains
rerender({
machineError: 0 as number | undefined,
machineErrorMessage: null as string | null,
pyodideError: "Pyodide error" as string | null,
});
// Should stay open because pyodide error still exists
expect(result.current.isOpen).toBe(true);
// Clear all errors
rerender({
machineError: 0 as number | undefined,
machineErrorMessage: null as string | null,
pyodideError: null as string | null,
});
expect(result.current.isOpen).toBe(false);
});
});

View file

@ -0,0 +1,140 @@
/**
* useErrorPopoverState Hook
*
* Manages error popover state with sophisticated auto-open/close behavior.
* Automatically opens when new errors appear and closes when all errors are cleared.
* Tracks manual dismissal to prevent reopening for the same error.
*
* This hook is designed for multi-source error handling (e.g., machine errors,
* pyodide errors, error messages) and provides a consistent UX for error notification.
*
* @param options - Configuration options
* @param options.machineError - Current machine error code
* @param options.machineErrorMessage - Current machine error message
* @param options.pyodideError - Current Pyodide error message
* @param options.hasError - Function to check if an error code represents an error
* @returns Object containing popover state and control functions
*
* @example
* ```tsx
* const { isOpen, handleOpenChange } = useErrorPopoverState({
* machineError,
* machineErrorMessage,
* pyodideError,
* hasError: (code) => code !== 0 && code !== undefined
* });
*
* return (
* <Popover open={isOpen} onOpenChange={handleOpenChange}>
* <PopoverContent>{errorMessage}</PopoverContent>
* </Popover>
* );
* ```
*/
import { useState, useEffect } from "react";
import { usePrevious } from "../utility/usePrevious";
export interface UseErrorPopoverStateOptions {
machineError: number | undefined;
machineErrorMessage: string | null;
pyodideError: string | null;
hasError: (error: number | undefined) => boolean;
}
export interface UseErrorPopoverStateReturn {
isOpen: boolean;
handleOpenChange: (open: boolean) => void;
dismissedErrorCode: number | null;
wasManuallyDismissed: boolean;
}
export function useErrorPopoverState(
options: UseErrorPopoverStateOptions,
): UseErrorPopoverStateReturn {
const { machineError, machineErrorMessage, pyodideError, hasError } = options;
// Internal state
const [isOpen, setIsOpen] = useState(false);
const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>(
null,
);
const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false);
// Track previous values for comparison
const prevMachineError = usePrevious(machineError);
const prevErrorMessage = usePrevious(machineErrorMessage);
const prevPyodideError = usePrevious(pyodideError);
// Auto-open/close logic
// Note: This effect intentionally calls setState to synchronize popover state with error state.
// This is a valid use case for setState in an effect as we're synchronizing external state
// (error codes) with internal UI state (popover visibility).
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
// Check if there's any error now
const hasAnyError =
machineErrorMessage || pyodideError || hasError(machineError);
// Check if there was any error before
const hadAnyError =
prevErrorMessage || prevPyodideError || hasError(prevMachineError);
// Auto-open popover when new error appears (but not if user manually dismissed)
const isNewMachineError =
hasError(machineError) &&
machineError !== prevMachineError &&
machineError !== dismissedErrorCode;
const isNewErrorMessage =
machineErrorMessage && machineErrorMessage !== prevErrorMessage;
const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError;
if (
!wasManuallyDismissed &&
(isNewMachineError || isNewErrorMessage || isNewPyodideError)
) {
setIsOpen(true);
}
// Auto-close popover when all errors are cleared
if (!hasAnyError && hadAnyError) {
setIsOpen(false);
setDismissedErrorCode(null); // Reset dismissed tracking
setWasManuallyDismissed(false); // Reset manual dismissal flag
}
}, [
machineError,
machineErrorMessage,
pyodideError,
dismissedErrorCode,
wasManuallyDismissed,
prevMachineError,
prevErrorMessage,
prevPyodideError,
hasError,
]);
/* eslint-enable react-hooks/set-state-in-effect */
// Handle manual popover dismiss
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
// If user manually closes it while any error is present, remember this to prevent reopening
if (
!open &&
(hasError(machineError) || machineErrorMessage || pyodideError)
) {
setWasManuallyDismissed(true);
// Also track the specific machine error code if present
if (hasError(machineError) && machineError !== undefined) {
setDismissedErrorCode(machineError);
}
}
};
return {
isOpen,
handleOpenChange,
dismissedErrorCode,
wasManuallyDismissed,
};
}

View file

@ -0,0 +1,158 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useMachinePolling } from "./useMachinePolling";
import { MachineStatus } from "../../types/machine";
describe("useMachinePolling", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("should start polling when startPolling is called", async () => {
const onStatusRefresh = vi.fn().mockResolvedValue(undefined);
const onProgressRefresh = vi.fn().mockResolvedValue(undefined);
const onServiceCountRefresh = vi.fn().mockResolvedValue(undefined);
const onPatternInfoRefresh = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useMachinePolling({
machineStatus: MachineStatus.IDLE,
patternInfo: null,
onStatusRefresh,
onProgressRefresh,
onServiceCountRefresh,
onPatternInfoRefresh,
shouldCheckResumablePattern: () => false,
}),
);
expect(result.current.isPolling).toBe(false);
act(() => {
result.current.startPolling();
});
await waitFor(() => {
expect(result.current.isPolling).toBe(true);
});
});
it("should stop polling when stopPolling is called", async () => {
const onStatusRefresh = vi.fn().mockResolvedValue(undefined);
const onProgressRefresh = vi.fn().mockResolvedValue(undefined);
const onServiceCountRefresh = vi.fn().mockResolvedValue(undefined);
const onPatternInfoRefresh = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useMachinePolling({
machineStatus: MachineStatus.IDLE,
patternInfo: null,
onStatusRefresh,
onProgressRefresh,
onServiceCountRefresh,
onPatternInfoRefresh,
shouldCheckResumablePattern: () => false,
}),
);
act(() => {
result.current.startPolling();
});
await waitFor(() => {
expect(result.current.isPolling).toBe(true);
});
act(() => {
result.current.stopPolling();
});
await waitFor(() => {
expect(result.current.isPolling).toBe(false);
});
});
it("should initialize polling correctly for SEWING state", async () => {
const onStatusRefresh = vi.fn().mockResolvedValue(undefined);
const onProgressRefresh = vi.fn().mockResolvedValue(undefined);
const onServiceCountRefresh = vi.fn().mockResolvedValue(undefined);
const onPatternInfoRefresh = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useMachinePolling({
machineStatus: MachineStatus.SEWING,
patternInfo: null,
onStatusRefresh,
onProgressRefresh,
onServiceCountRefresh,
onPatternInfoRefresh,
shouldCheckResumablePattern: () => false,
}),
);
act(() => {
result.current.startPolling();
});
await waitFor(() => {
expect(result.current.isPolling).toBe(true);
});
act(() => {
result.current.stopPolling();
});
await waitFor(() => {
expect(result.current.isPolling).toBe(false);
});
});
it("should initialize polling for different machine states", async () => {
const createMocks = () => ({
onStatusRefresh: vi.fn().mockResolvedValue(undefined),
onProgressRefresh: vi.fn().mockResolvedValue(undefined),
onServiceCountRefresh: vi.fn().mockResolvedValue(undefined),
onPatternInfoRefresh: vi.fn().mockResolvedValue(undefined),
});
// Test COLOR_CHANGE_WAIT state
const mocks1 = createMocks();
const { result: result1 } = renderHook(() =>
useMachinePolling({
machineStatus: MachineStatus.COLOR_CHANGE_WAIT,
patternInfo: null,
...mocks1,
shouldCheckResumablePattern: () => false,
}),
);
act(() => {
result1.current.startPolling();
});
await waitFor(() => {
expect(result1.current.isPolling).toBe(true);
});
act(() => {
result1.current.stopPolling();
});
// Test READY state
const mocks2 = createMocks();
const { result: result2 } = renderHook(() =>
useMachinePolling({
machineStatus: MachineStatus.IDLE,
patternInfo: null,
...mocks2,
shouldCheckResumablePattern: () => false,
}),
);
act(() => {
result2.current.startPolling();
});
await waitFor(() => {
expect(result2.current.isPolling).toBe(true);
});
act(() => {
result2.current.stopPolling();
});
});
});

View file

@ -0,0 +1,186 @@
/**
* useMachinePolling Hook
*
* Implements dynamic polling for machine status based on machine state.
* Uses adaptive polling intervals and conditional progress polling during sewing.
*
* Polling intervals:
* - 500ms for active states (SEWING, MASK_TRACING, SEWING_DATA_RECEIVE)
* - 1000ms for waiting states (COLOR_CHANGE_WAIT, MASK_TRACE_LOCK_WAIT, SEWING_WAIT)
* - 2000ms for idle/other states
*
* Additionally polls service count every 10 seconds.
*
* @param options - Configuration options
* @param options.machineStatus - Current machine status to determine polling interval
* @param options.patternInfo - Current pattern info for resumable pattern check
* @param options.onStatusRefresh - Callback to refresh machine status
* @param options.onProgressRefresh - Callback to refresh sewing progress
* @param options.onServiceCountRefresh - Callback to refresh service count
* @param options.onPatternInfoRefresh - Callback to refresh pattern info
* @param options.shouldCheckResumablePattern - Function to check if resumable pattern exists
* @returns Object containing start/stop functions and polling state
*
* @example
* ```tsx
* const { startPolling, stopPolling, isPolling } = useMachinePolling({
* machineStatus,
* patternInfo,
* onStatusRefresh: async () => { ... },
* onProgressRefresh: async () => { ... },
* onServiceCountRefresh: async () => { ... },
* onPatternInfoRefresh: async () => { ... },
* shouldCheckResumablePattern: () => resumeAvailable
* });
*
* useEffect(() => {
* startPolling();
* return () => stopPolling();
* }, []);
* ```
*/
import { useState, useCallback, useRef, useEffect } from "react";
import { MachineStatus } from "../../types/machine";
import type { PatternInfo } from "../../types/machine";
export interface UseMachinePollingOptions {
machineStatus: MachineStatus;
patternInfo: PatternInfo | null;
onStatusRefresh: () => Promise<void>;
onProgressRefresh: () => Promise<void>;
onServiceCountRefresh: () => Promise<void>;
onPatternInfoRefresh: () => Promise<void>;
shouldCheckResumablePattern: () => boolean;
}
export interface UseMachinePollingReturn {
startPolling: () => void;
stopPolling: () => void;
isPolling: boolean;
}
export function useMachinePolling(
options: UseMachinePollingOptions,
): UseMachinePollingReturn {
const {
machineStatus,
patternInfo,
onStatusRefresh,
onProgressRefresh,
onServiceCountRefresh,
onPatternInfoRefresh,
shouldCheckResumablePattern,
} = options;
const [isPolling, setIsPolling] = useState(false);
const pollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const serviceCountIntervalRef = useRef<NodeJS.Timeout | null>(null);
const pollFunctionRef = useRef<(() => Promise<void>) | undefined>(undefined);
// Store callbacks in refs to avoid unnecessary re-renders
const callbacksRef = useRef({
onStatusRefresh,
onProgressRefresh,
onServiceCountRefresh,
onPatternInfoRefresh,
shouldCheckResumablePattern,
});
// Update refs when callbacks change
useEffect(() => {
callbacksRef.current = {
onStatusRefresh,
onProgressRefresh,
onServiceCountRefresh,
onPatternInfoRefresh,
shouldCheckResumablePattern,
};
});
// Function to determine polling interval based on machine status
const getPollInterval = useCallback((status: MachineStatus) => {
// Fast polling for active states
if (
status === MachineStatus.SEWING ||
status === MachineStatus.MASK_TRACING ||
status === MachineStatus.SEWING_DATA_RECEIVE
) {
return 500;
} else if (
status === MachineStatus.COLOR_CHANGE_WAIT ||
status === MachineStatus.MASK_TRACE_LOCK_WAIT ||
status === MachineStatus.SEWING_WAIT
) {
return 1000;
}
return 2000; // Default for idle states
}, []);
// Main polling function
const poll = useCallback(async () => {
await callbacksRef.current.onStatusRefresh();
// Refresh progress during sewing
if (machineStatus === MachineStatus.SEWING) {
await callbacksRef.current.onProgressRefresh();
}
// Check if we have a cached pattern and pattern info needs refreshing
// This follows the app's logic for resumable patterns
if (
callbacksRef.current.shouldCheckResumablePattern() &&
patternInfo?.totalStitches === 0
) {
await callbacksRef.current.onPatternInfoRefresh();
}
// Schedule next poll with updated interval
const newInterval = getPollInterval(machineStatus);
if (pollFunctionRef.current) {
pollTimeoutRef.current = setTimeout(pollFunctionRef.current, newInterval);
}
}, [machineStatus, patternInfo, getPollInterval]);
// Store poll function in ref for recursive setTimeout
useEffect(() => {
pollFunctionRef.current = poll;
}, [poll]);
const stopPolling = useCallback(() => {
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current);
pollTimeoutRef.current = null;
}
if (serviceCountIntervalRef.current) {
clearInterval(serviceCountIntervalRef.current);
serviceCountIntervalRef.current = null;
}
setIsPolling(false);
}, []);
const startPolling = useCallback(() => {
// Stop any existing polling
stopPolling();
// Start main polling
const initialInterval = getPollInterval(machineStatus);
pollTimeoutRef.current = setTimeout(poll, initialInterval);
// Start service count polling (every 10 seconds)
serviceCountIntervalRef.current = setInterval(
callbacksRef.current.onServiceCountRefresh,
10000,
);
setIsPolling(true);
}, [machineStatus, poll, stopPolling, getPollInterval]);
return {
startPolling,
stopPolling,
isPolling,
};
}

View file

@ -1,12 +1,12 @@
import { useCallback } from "react";
import type { PesPatternData } from "../formats/import/pesImporter";
import { transformStitchesRotation } from "../utils/rotationUtils";
import { encodeStitchesToPen } from "../formats/pen/encoder";
import { decodePenData } from "../formats/pen/decoder";
import type { PesPatternData } from "../../formats/import/pesImporter";
import { transformStitchesRotation } from "../../utils/rotationUtils";
import { encodeStitchesToPen } from "../../formats/pen/encoder";
import { decodePenData } from "../../formats/pen/decoder";
import {
calculatePatternCenter,
calculateBoundsFromDecodedStitches,
} from "../components/PatternCanvas/patternCanvasHelpers";
} from "../../components/PatternCanvas/patternCanvasHelpers";
export interface UsePatternRotationUploadParams {
uploadPattern: (

View file

@ -1,8 +1,8 @@
import { useMemo } from "react";
import type { PesPatternData } from "../formats/import/pesImporter";
import type { MachineInfo } from "../types/machine";
import { calculateRotatedBounds } from "../utils/rotationUtils";
import { calculatePatternCenter } from "../components/PatternCanvas/patternCanvasHelpers";
import type { PesPatternData } from "../../formats/import/pesImporter";
import type { MachineInfo } from "../../types/machine";
import { calculateRotatedBounds } from "../../utils/rotationUtils";
import { calculatePatternCenter } from "../../components/PatternCanvas/patternCanvasHelpers";
export interface PatternBoundsCheckResult {
fits: boolean;

11
src/hooks/index.ts Normal file
View file

@ -0,0 +1,11 @@
// Utility Hooks - Generic, reusable patterns
export * from "./utility";
// Domain Hooks - Business logic for embroidery/pattern operations
export * from "./domain";
// UI Hooks - Library/framework integration (Konva, etc.)
export * from "./ui";
// Platform Hooks - Electron/Pyodide specific functionality
export * from "./platform";

View file

@ -0,0 +1,3 @@
export { useFileUpload } from "./useFileUpload";
export { useBluetoothDeviceListener } from "./useBluetoothDeviceListener";
export type { UseBluetoothDeviceListenerReturn } from "./useBluetoothDeviceListener";

View file

@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useBluetoothDeviceListener } from "./useBluetoothDeviceListener";
import type { BluetoothDevice } from "../../types/electron";
describe("useBluetoothDeviceListener", () => {
beforeEach(() => {
// Reset window.electronAPI before each test
delete (window as { electronAPI?: unknown }).electronAPI;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("should return empty state when Electron API is not available", () => {
const { result } = renderHook(() => useBluetoothDeviceListener());
expect(result.current.devices).toEqual([]);
expect(result.current.isScanning).toBe(false);
expect(result.current.isSupported).toBe(false);
});
it("should return isSupported=true when Electron API is available", () => {
// Mock Electron API
(
window as unknown as {
electronAPI: { onBluetoothDeviceList: () => void };
}
).electronAPI = {
onBluetoothDeviceList: vi.fn(),
};
const { result } = renderHook(() => useBluetoothDeviceListener());
expect(result.current.isSupported).toBe(true);
});
it("should register IPC listener when Electron API is available", () => {
const mockListener = vi.fn();
(
window as unknown as {
electronAPI: { onBluetoothDeviceList: typeof mockListener };
}
).electronAPI = {
onBluetoothDeviceList: mockListener,
};
renderHook(() => useBluetoothDeviceListener());
expect(mockListener).toHaveBeenCalledTimes(1);
expect(mockListener).toHaveBeenCalledWith(expect.any(Function));
});
it("should update devices when listener receives data", async () => {
let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null =
null;
const mockListener = vi.fn(
(callback: (devices: BluetoothDevice[]) => void) => {
deviceListCallback = callback;
},
);
(
window as unknown as {
electronAPI: { onBluetoothDeviceList: typeof mockListener };
}
).electronAPI = {
onBluetoothDeviceList: mockListener,
};
const { result } = renderHook(() => useBluetoothDeviceListener());
expect(result.current.devices).toEqual([]);
// Simulate device list update
const mockDevices: BluetoothDevice[] = [
{ deviceId: "device1", deviceName: "Device 1" },
{ deviceId: "device2", deviceName: "Device 2" },
];
// Trigger the callback
act(() => {
deviceListCallback!(mockDevices);
});
await waitFor(() => {
expect(result.current.devices).toEqual(mockDevices);
});
});
it("should set isScanning=true when empty device list received", async () => {
let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null =
null;
const mockListener = vi.fn(
(callback: (devices: BluetoothDevice[]) => void) => {
deviceListCallback = callback;
},
);
(
window as unknown as {
electronAPI: { onBluetoothDeviceList: typeof mockListener };
}
).electronAPI = {
onBluetoothDeviceList: mockListener,
};
const { result } = renderHook(() => useBluetoothDeviceListener());
// Simulate empty device list (scanning in progress)
act(() => {
deviceListCallback!([]);
});
await waitFor(() => {
expect(result.current.isScanning).toBe(true);
});
expect(result.current.devices).toEqual([]);
});
it("should set isScanning=false when devices are received", async () => {
let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null =
null;
const mockListener = vi.fn(
(callback: (devices: BluetoothDevice[]) => void) => {
deviceListCallback = callback;
},
);
(
window as unknown as {
electronAPI: { onBluetoothDeviceList: typeof mockListener };
}
).electronAPI = {
onBluetoothDeviceList: mockListener,
};
const { result } = renderHook(() => useBluetoothDeviceListener());
// First update: empty list (scanning)
act(() => {
deviceListCallback!([]);
});
await waitFor(() => {
expect(result.current.isScanning).toBe(true);
});
// Second update: devices found (stop scanning indicator)
const mockDevices: BluetoothDevice[] = [
{ deviceId: "device1", deviceName: "Device 1" },
];
act(() => {
deviceListCallback!(mockDevices);
});
await waitFor(() => {
expect(result.current.isScanning).toBe(false);
});
expect(result.current.devices).toEqual(mockDevices);
});
it("should call optional callback when devices change", async () => {
let deviceListCallback: ((devices: BluetoothDevice[]) => void) | null =
null;
const mockListener = vi.fn(
(callback: (devices: BluetoothDevice[]) => void) => {
deviceListCallback = callback;
},
);
(
window as unknown as {
electronAPI: { onBluetoothDeviceList: typeof mockListener };
}
).electronAPI = {
onBluetoothDeviceList: mockListener,
};
const onDevicesChanged = vi.fn();
renderHook(() => useBluetoothDeviceListener(onDevicesChanged));
const mockDevices: BluetoothDevice[] = [
{ deviceId: "device1", deviceName: "Device 1" },
];
act(() => {
deviceListCallback!(mockDevices);
});
await waitFor(() => {
expect(onDevicesChanged).toHaveBeenCalledWith(mockDevices);
});
});
});

View file

@ -0,0 +1,98 @@
/**
* useBluetoothDeviceListener Hook
*
* Listens for Bluetooth device discovery events from Electron IPC.
* Automatically manages device list state and provides platform detection.
*
* This hook is Electron-specific and will gracefully handle browser environments
* by returning empty state.
*
* @param onDevicesChanged - Optional callback when device list changes
* @returns Object containing devices array, scanning state, and platform support flag
*
* @example
* ```tsx
* const { devices, isScanning, isSupported } = useBluetoothDeviceListener(
* (devices) => {
* if (devices.length > 0) {
* console.log('Devices found:', devices);
* }
* }
* );
*
* if (!isSupported) {
* return <div>Bluetooth pairing only available in Electron app</div>;
* }
*
* return (
* <div>
* {isScanning && <p>Scanning...</p>}
* {devices.map(device => <div key={device.id}>{device.name}</div>)}
* </div>
* );
* ```
*/
import { useEffect, useState, useRef } from "react";
import type { BluetoothDevice } from "../../types/electron";
export interface UseBluetoothDeviceListenerReturn {
devices: BluetoothDevice[];
isScanning: boolean;
isSupported: boolean;
}
export function useBluetoothDeviceListener(
onDevicesChanged?: (devices: BluetoothDevice[]) => void,
): UseBluetoothDeviceListenerReturn {
const [devices, setDevices] = useState<BluetoothDevice[]>([]);
const [isScanning, setIsScanning] = useState(false);
// Store callback in ref to avoid re-registering listener
const callbackRef = useRef(onDevicesChanged);
// Update ref when callback changes
useEffect(() => {
callbackRef.current = onDevicesChanged;
});
// Check if Electron API is available
const isSupported =
typeof window !== "undefined" &&
!!window.electronAPI?.onBluetoothDeviceList;
useEffect(() => {
// Only set up listener in Electron
if (!isSupported) {
return;
}
const handleDeviceList = (deviceList: BluetoothDevice[]) => {
setDevices(deviceList);
// Start scanning when first update received
if (deviceList.length === 0) {
setIsScanning(true);
} else {
// Stop showing scanning state once we have devices
setIsScanning(false);
}
// Call optional callback using ref to get latest version
callbackRef.current?.(deviceList);
};
// Register listener only once
window.electronAPI!.onBluetoothDeviceList(handleDeviceList);
// Note: Electron IPC listeners are typically not cleaned up individually
// as they're meant to persist. If cleanup is needed, the Electron main
// process should handle it.
}, [isSupported]);
return {
devices,
isScanning,
isSupported,
};
}

View file

@ -2,8 +2,8 @@ import { useState, useCallback } from "react";
import {
convertPesToPen,
type PesPatternData,
} from "../formats/import/pesImporter";
import type { IFileService } from "../platform/interfaces/IFileService";
} from "../../formats/import/pesImporter";
import type { IFileService } from "../../platform/interfaces/IFileService";
export interface UseFileUploadParams {
fileService: IFileService;

2
src/hooks/ui/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { useCanvasViewport } from "./useCanvasViewport";
export { usePatternTransform } from "./usePatternTransform";

View file

@ -7,10 +7,10 @@
import { useState, useEffect, useCallback, type RefObject } from "react";
import type Konva from "konva";
import type { PesPatternData } from "../formats/import/pesImporter";
import type { MachineInfo } from "../types/machine";
import { calculateInitialScale } from "../utils/konvaRenderers";
import { calculateZoomToPoint } from "../components/PatternCanvas/patternCanvasHelpers";
import type { PesPatternData } from "../../formats/import/pesImporter";
import type { MachineInfo } from "../../types/machine";
import { calculateInitialScale } from "../../utils/konvaRenderers";
import { calculateZoomToPoint } from "../../components/PatternCanvas/patternCanvasHelpers";
interface UseCanvasViewportOptions {
containerRef: RefObject<HTMLDivElement | null>;

View file

@ -8,7 +8,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node";
import type { PesPatternData } from "../formats/import/pesImporter";
import type { PesPatternData } from "../../formats/import/pesImporter";
interface UsePatternTransformOptions {
pesData: PesPatternData | null;

View file

@ -0,0 +1,5 @@
export { usePrevious } from "./usePrevious";
export { useAutoScroll } from "./useAutoScroll";
export { useClickOutside } from "./useClickOutside";
export type { UseAutoScrollOptions } from "./useAutoScroll";
export type { UseClickOutsideOptions } from "./useClickOutside";

View file

@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useAutoScroll } from "./useAutoScroll";
describe("useAutoScroll", () => {
beforeEach(() => {
// Mock scrollIntoView
Element.prototype.scrollIntoView = vi.fn();
});
it("should return a ref object", () => {
const { result } = renderHook(() => useAutoScroll(0));
expect(result.current).toHaveProperty("current");
});
it("should call scrollIntoView when dependency changes", () => {
const mockElement = document.createElement("div");
const scrollIntoViewMock = vi.fn();
mockElement.scrollIntoView = scrollIntoViewMock;
const { result, rerender } = renderHook(({ dep }) => useAutoScroll(dep), {
initialProps: { dep: 0 },
});
// Attach mock element to ref
(result.current as { current: HTMLElement }).current = mockElement;
// Change dependency to trigger effect
rerender({ dep: 1 });
expect(scrollIntoViewMock).toHaveBeenCalledWith({
behavior: "smooth",
block: "nearest",
inline: undefined,
});
});
it("should use custom scroll options", () => {
const mockElement = document.createElement("div");
const scrollIntoViewMock = vi.fn();
mockElement.scrollIntoView = scrollIntoViewMock;
const { result, rerender } = renderHook(
({ dep }) =>
useAutoScroll(dep, {
behavior: "auto",
block: "start",
inline: "center",
}),
{ initialProps: { dep: 0 } },
);
(result.current as { current: HTMLElement }).current = mockElement;
rerender({ dep: 1 });
expect(scrollIntoViewMock).toHaveBeenCalledWith({
behavior: "auto",
block: "start",
inline: "center",
});
});
it("should not call scrollIntoView if ref is not attached", () => {
const { rerender } = renderHook(({ dep }) => useAutoScroll(dep), {
initialProps: { dep: 0 },
});
// Change dependency without attaching ref
rerender({ dep: 1 });
// Should not throw or cause errors
expect(true).toBe(true);
});
});

View file

@ -0,0 +1,57 @@
/**
* useAutoScroll Hook
*
* Automatically scrolls an element into view when a dependency changes.
* Useful for keeping the current item visible in scrollable lists.
*
* @param dependency - The value to watch for changes (e.g., current index)
* @param options - Scroll behavior options
* @returns RefObject to attach to the element that should be scrolled into view
*
* @example
* ```tsx
* const currentItemRef = useAutoScroll(currentIndex, {
* behavior: "smooth",
* block: "nearest"
* });
*
* return (
* <div ref={isCurrent ? currentItemRef : null}>
* Current Item
* </div>
* );
* ```
*/
import { useEffect, useRef, useMemo, type RefObject } from "react";
export interface UseAutoScrollOptions {
behavior?: ScrollBehavior;
block?: ScrollLogicalPosition;
inline?: ScrollLogicalPosition;
}
export function useAutoScroll<T extends HTMLElement = HTMLElement>(
dependency: unknown,
options?: UseAutoScrollOptions,
): RefObject<T | null> {
const ref = useRef<T>(null);
// Stabilize options to avoid unnecessary re-renders when passed as inline object
const stableOptions = useMemo(
() => ({
behavior: options?.behavior || "smooth",
block: options?.block || "nearest",
inline: options?.inline,
}),
[options?.behavior, options?.block, options?.inline],
);
useEffect(() => {
if (ref.current) {
ref.current.scrollIntoView(stableOptions);
}
}, [dependency, stableOptions]);
return ref;
}

View file

@ -0,0 +1,159 @@
import { describe, it, expect, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useClickOutside } from "./useClickOutside";
import { useRef, type RefObject } from "react";
describe("useClickOutside", () => {
it("should call handler when clicking outside element", () => {
const handler = vi.fn();
const { result } = renderHook(() => {
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, handler);
return ref;
});
// Create and attach mock element
const element = document.createElement("div");
document.body.appendChild(element);
(result.current as { current: HTMLDivElement }).current = element;
// Click outside
const outsideElement = document.createElement("div");
document.body.appendChild(outsideElement);
outsideElement.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
expect(handler).toHaveBeenCalledTimes(1);
// Cleanup
document.body.removeChild(element);
document.body.removeChild(outsideElement);
});
it("should not call handler when clicking inside element", () => {
const handler = vi.fn();
const { result } = renderHook(() => {
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, handler);
return ref;
});
const element = document.createElement("div");
document.body.appendChild(element);
(result.current as { current: HTMLDivElement }).current = element;
// Click inside
element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(handler).not.toHaveBeenCalled();
document.body.removeChild(element);
});
it("should respect enabled option", () => {
const handler = vi.fn();
const { result, rerender } = renderHook(
({ enabled }) => {
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, handler, { enabled });
return ref;
},
{ initialProps: { enabled: false } },
);
const element = document.createElement("div");
document.body.appendChild(element);
(result.current as { current: HTMLDivElement }).current = element;
// Click outside while disabled
const outsideElement = document.createElement("div");
document.body.appendChild(outsideElement);
outsideElement.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
expect(handler).not.toHaveBeenCalled();
// Enable and click outside again
rerender({ enabled: true });
outsideElement.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
expect(handler).toHaveBeenCalledTimes(1);
document.body.removeChild(element);
document.body.removeChild(outsideElement);
});
it("should not call handler when clicking excluded refs", () => {
const handler = vi.fn();
const { result } = renderHook(() => {
const ref = useRef<HTMLDivElement>(null);
const excludeRef = useRef<HTMLElement>(null);
useClickOutside(ref, handler, {
excludeRefs: [excludeRef as unknown as RefObject<HTMLElement>],
});
return { ref, excludeRef };
});
const element = document.createElement("div");
const excludedElement = document.createElement("button");
document.body.appendChild(element);
document.body.appendChild(excludedElement);
(result.current.ref as { current: HTMLDivElement }).current = element;
(result.current.excludeRef as { current: HTMLElement }).current =
excludedElement;
// Click on excluded element
excludedElement.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
expect(handler).not.toHaveBeenCalled();
document.body.removeChild(element);
document.body.removeChild(excludedElement);
});
it("should handle object of refs (WorkflowStepper pattern)", () => {
const handler = vi.fn();
const { result } = renderHook(() => {
const ref = useRef<HTMLDivElement>(null);
const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
useClickOutside(ref, handler, { excludeRefs: [stepRefs] });
return { ref, stepRefs };
});
const element = document.createElement("div");
const step1 = document.createElement("div");
const step2 = document.createElement("div");
document.body.appendChild(element);
document.body.appendChild(step1);
document.body.appendChild(step2);
(result.current.ref as { current: HTMLDivElement }).current = element;
(
result.current.stepRefs as {
current: { [key: number]: HTMLDivElement | null };
}
).current = {
1: step1,
2: step2,
};
// Click on step1 (excluded)
step1.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(handler).not.toHaveBeenCalled();
// Click on step2 (excluded)
step2.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(handler).not.toHaveBeenCalled();
document.body.removeChild(element);
document.body.removeChild(step1);
document.body.removeChild(step2);
});
});

View file

@ -0,0 +1,89 @@
/**
* useClickOutside Hook
*
* Detects clicks outside a referenced element and executes a handler function.
* Useful for closing dropdown menus, popovers, modals, and other overlay UI elements.
*
* @param ref - Reference to the element to monitor for outside clicks
* @param handler - Callback function to execute when outside click is detected
* @param options - Configuration options
* @param options.enabled - Whether the listener is active (default: true)
* @param options.excludeRefs - Array of refs that should not trigger the handler when clicked
*
* @example
* ```tsx
* const dropdownRef = useRef<HTMLDivElement>(null);
* const buttonRef = useRef<HTMLButtonElement>(null);
*
* useClickOutside(
* dropdownRef,
* () => setIsOpen(false),
* {
* enabled: isOpen,
* excludeRefs: [buttonRef] // Don't close when clicking the button
* }
* );
*
* return (
* <>
* <button ref={buttonRef}>Toggle</button>
* {isOpen && <div ref={dropdownRef}>Content</div>}
* </>
* );
* ```
*/
import { useEffect, type RefObject } from "react";
export interface UseClickOutsideOptions {
enabled?: boolean;
excludeRefs?: (
| RefObject<HTMLElement>
| { current: Record<string, HTMLElement | null> }
)[];
}
export function useClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T | null>,
handler: (event: MouseEvent) => void,
options?: UseClickOutsideOptions,
): void {
const { enabled = true, excludeRefs = [] } = options || {};
useEffect(() => {
if (!enabled) return;
const handleClickOutside = (event: MouseEvent) => {
// Check if click is outside the main ref
if (ref.current && !ref.current.contains(event.target as Node)) {
// Check if click is on any excluded refs
const clickedExcluded = excludeRefs.some((excludeRef) => {
if (!excludeRef.current) return false;
// Handle object of refs (e.g., { [key: number]: HTMLElement | null })
if (
typeof excludeRef.current === "object" &&
!("nodeType" in excludeRef.current)
) {
return Object.values(excludeRef.current).some((element) =>
element?.contains(event.target as Node),
);
}
// Handle single ref
return (excludeRef.current as HTMLElement).contains(
event.target as Node,
);
});
// Only call handler if click was not on excluded elements
if (!clickedExcluded) {
handler(event);
}
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [ref, handler, enabled, excludeRefs]);
}

View file

@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { renderHook } from "@testing-library/react";
import { usePrevious } from "./usePrevious";
describe("usePrevious", () => {
it("should return undefined on initial render", () => {
const { result } = renderHook(() => usePrevious(5));
expect(result.current).toBeUndefined();
});
it("should return previous value after update", () => {
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
initialProps: { value: 5 },
});
expect(result.current).toBeUndefined();
rerender({ value: 10 });
expect(result.current).toBe(5);
rerender({ value: 15 });
expect(result.current).toBe(10);
});
it("should handle different types of values", () => {
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
initialProps: { value: "hello" as string | number | null },
});
expect(result.current).toBeUndefined();
rerender({ value: 42 });
expect(result.current).toBe("hello");
rerender({ value: null });
expect(result.current).toBe(42);
});
it("should handle object references", () => {
const obj1 = { name: "first" };
const obj2 = { name: "second" };
const { result, rerender } = renderHook(({ value }) => usePrevious(value), {
initialProps: { value: obj1 },
});
expect(result.current).toBeUndefined();
rerender({ value: obj2 });
expect(result.current).toBe(obj1);
});
});

52
src/stores/storeEvents.ts Normal file
View file

@ -0,0 +1,52 @@
/**
* Store Events
*
* Zustand-based event store for cross-store communication without tight coupling.
* Uses Zustand's built-in subscription system to emit and react to events.
*/
import { create } from "zustand";
interface EventState {
// Event counters - incrementing these triggers subscriptions
patternDeletedCount: number;
// Actions to emit events
emitPatternDeleted: () => void;
}
/**
* Event store using Zustand for cross-store communication.
* Stores can emit events by calling actions, and subscribe to events using Zustand's subscribe.
*/
export const useEventStore = create<EventState>((set) => ({
patternDeletedCount: 0,
emitPatternDeleted: () => {
set((state) => ({ patternDeletedCount: state.patternDeletedCount + 1 }));
},
}));
/**
* Subscribe to the pattern deleted event.
*
* The subscription remains active until the returned unsubscribe function is called.
* If the unsubscribe function is not called, the listener will persist for the
* lifetime of the event store (typically the lifetime of the application).
*
* Call the returned unsubscribe function when the listener is no longer needed,
* especially for short-lived components or non-module-level subscriptions.
*
* @param callback - Function to call when the event is emitted.
* @returns Unsubscribe function that removes the listener when invoked.
*/
export const onPatternDeleted = (callback: () => void): (() => void) => {
let prevCount = useEventStore.getState().patternDeletedCount;
return useEventStore.subscribe((state) => {
if (state.patternDeletedCount !== prevCount) {
prevCount = state.patternDeletedCount;
callback();
}
});
};

View file

@ -1,6 +1,7 @@
import { create } from "zustand";
import type { PesPatternData } from "../formats/import/pesImporter";
import { uuidToString } from "../services/PatternCacheService";
import { onPatternDeleted } from "./storeEvents";
/**
* Machine Cache Store
@ -192,3 +193,18 @@ export const useMachineCacheStore = create<MachineCacheState>((set, get) => ({
});
},
}));
// Subscribe to pattern deleted event.
// This subscription is intended to persist for the lifetime of the application,
// so the unsubscribe function returned by `onPatternDeleted` is intentionally
// not stored or called.
onPatternDeleted(() => {
try {
useMachineCacheStore.getState().clearResumeState();
} catch (error) {
console.error(
"[MachineCacheStore] Failed to clear resume state on pattern deleted event:",
error,
);
}
});

View file

@ -13,7 +13,7 @@ import { SewingMachineError } from "../utils/errorCodeHelpers";
import { uuidToString } from "../services/PatternCacheService";
import { createStorageService } from "../platform";
import type { IStorageService } from "../platform/interfaces/IStorageService";
import { usePatternStore } from "./usePatternStore";
import { useEventStore } from "./storeEvents";
interface MachineState {
// Service instances
@ -291,16 +291,8 @@ export const useMachineStore = create<MachineState>((set, get) => ({
sewingProgress: null,
});
// Clear uploaded pattern data in pattern store
usePatternStore.getState().clearUploadedPattern();
// Clear upload state in upload store
const { useMachineUploadStore } = await import("./useMachineUploadStore");
useMachineUploadStore.getState().reset();
// Clear resume state in cache store
const { useMachineCacheStore } = await import("./useMachineCacheStore");
useMachineCacheStore.getState().clearResumeState();
// Emit pattern deleted event for other stores to react
useEventStore.getState().emitPatternDeleted();
await refreshStatus();
} catch (err) {

View file

@ -1,6 +1,7 @@
import { create } from "zustand";
import type { PesPatternData } from "../formats/import/pesImporter";
import { uuidToString } from "../services/PatternCacheService";
import { onPatternDeleted } from "./storeEvents";
/**
* Machine Upload Store
@ -126,3 +127,18 @@ export const useMachineUploadStore = create<MachineUploadState>((set) => ({
set({ uploadProgress: 0, isUploading: false });
},
}));
// Subscribe to pattern deleted event.
// This subscription is intended to persist for the lifetime of the application,
// so the unsubscribe function returned by `onPatternDeleted` is intentionally
// not stored or called.
onPatternDeleted(() => {
try {
useMachineUploadStore.getState().reset();
} catch (error) {
console.error(
"[MachineUploadStore] Failed to reset on pattern deleted event:",
error,
);
}
});

View file

@ -1,5 +1,6 @@
import { create } from "zustand";
import type { PesPatternData } from "../formats/import/pesImporter";
import { onPatternDeleted } from "./storeEvents";
interface PatternState {
// Original pattern (pre-upload)
@ -121,3 +122,18 @@ export const useUploadedPatternOffset = () =>
usePatternStore((state) => state.uploadedPatternOffset);
export const usePatternRotation = () =>
usePatternStore((state) => state.patternRotation);
// Subscribe to pattern deleted event.
// This subscription is intended to persist for the lifetime of the application,
// so the unsubscribe function returned by `onPatternDeleted` is intentionally
// not stored or called.
onPatternDeleted(() => {
try {
usePatternStore.getState().clearUploadedPattern();
} catch (error) {
console.error(
"[PatternStore] Failed to clear uploaded pattern on pattern deleted event:",
error,
);
}
});

View file

@ -0,0 +1,241 @@
import { describe, it, expect } from "vitest";
import {
calculateColorBlocks,
findCurrentBlockIndex,
} from "./colorBlockHelpers";
import type { PesPatternData } from "../formats/import/client";
describe("colorBlockHelpers", () => {
describe("calculateColorBlocks", () => {
it("should return empty array when displayPattern is null", () => {
const result = calculateColorBlocks(null);
expect(result).toEqual([]);
});
it("should return empty array when penStitches is undefined", () => {
const pattern = {
penStitches: undefined,
} as unknown as PesPatternData;
const result = calculateColorBlocks(pattern);
expect(result).toEqual([]);
});
it("should calculate color blocks from PEN data", () => {
const pattern: Partial<PesPatternData> = {
threads: [
{
color: 1,
hex: "#FF0000",
brand: "Brother",
catalogNumber: "001",
description: "Red",
chart: "A",
},
{
color: 2,
hex: "#00FF00",
brand: "Brother",
catalogNumber: "002",
description: "Green",
chart: "B",
},
],
penStitches: {
colorBlocks: [
{
startStitchIndex: 0,
endStitchIndex: 100,
colorIndex: 0,
startStitch: 0,
endStitch: 100,
},
{
startStitchIndex: 100,
endStitchIndex: 250,
colorIndex: 1,
startStitch: 100,
endStitch: 250,
},
],
stitches: [],
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
},
};
const result = calculateColorBlocks(pattern as PesPatternData);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
colorIndex: 0,
threadHex: "#FF0000",
threadCatalogNumber: "001",
threadBrand: "Brother",
threadDescription: "Red",
threadChart: "A",
startStitch: 0,
endStitch: 100,
stitchCount: 100,
});
expect(result[1]).toEqual({
colorIndex: 1,
threadHex: "#00FF00",
threadCatalogNumber: "002",
threadBrand: "Brother",
threadDescription: "Green",
threadChart: "B",
startStitch: 100,
endStitch: 250,
stitchCount: 150,
});
});
it("should use fallback values when thread data is missing", () => {
const pattern: Partial<PesPatternData> = {
threads: [],
penStitches: {
colorBlocks: [
{
startStitchIndex: 0,
endStitchIndex: 50,
colorIndex: 0,
startStitch: 0,
endStitch: 50,
},
],
stitches: [],
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
},
};
const result = calculateColorBlocks(pattern as PesPatternData);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
colorIndex: 0,
threadHex: "#000000", // Fallback for missing thread
threadCatalogNumber: null,
threadBrand: null,
threadDescription: null,
threadChart: null,
startStitch: 0,
endStitch: 50,
stitchCount: 50,
});
});
it("should handle null thread metadata fields", () => {
const pattern: Partial<PesPatternData> = {
threads: [
{
color: 1,
hex: "#0000FF",
brand: null,
catalogNumber: null,
description: null,
chart: null,
},
],
penStitches: {
colorBlocks: [
{
startStitchIndex: 0,
endStitchIndex: 30,
colorIndex: 0,
startStitch: 0,
endStitch: 30,
},
],
stitches: [],
bounds: { minX: 0, maxX: 100, minY: 0, maxY: 100 },
},
};
const result = calculateColorBlocks(pattern as PesPatternData);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
colorIndex: 0,
threadHex: "#0000FF",
threadCatalogNumber: null,
threadBrand: null,
threadDescription: null,
threadChart: null,
startStitch: 0,
endStitch: 30,
stitchCount: 30,
});
});
});
describe("findCurrentBlockIndex", () => {
const colorBlocks = [
{
colorIndex: 0,
threadHex: "#FF0000",
threadCatalogNumber: "001",
threadBrand: "Brother",
threadDescription: "Red",
threadChart: "A",
startStitch: 0,
endStitch: 100,
stitchCount: 100,
},
{
colorIndex: 1,
threadHex: "#00FF00",
threadCatalogNumber: "002",
threadBrand: "Brother",
threadDescription: "Green",
threadChart: "B",
startStitch: 100,
endStitch: 250,
stitchCount: 150,
},
{
colorIndex: 2,
threadHex: "#0000FF",
threadCatalogNumber: "003",
threadBrand: "Brother",
threadDescription: "Blue",
threadChart: "C",
startStitch: 250,
endStitch: 400,
stitchCount: 150,
},
];
it("should find block containing stitch at start boundary", () => {
expect(findCurrentBlockIndex(colorBlocks, 0)).toBe(0);
expect(findCurrentBlockIndex(colorBlocks, 100)).toBe(1);
expect(findCurrentBlockIndex(colorBlocks, 250)).toBe(2);
});
it("should find block containing stitch in middle", () => {
expect(findCurrentBlockIndex(colorBlocks, 50)).toBe(0);
expect(findCurrentBlockIndex(colorBlocks, 150)).toBe(1);
expect(findCurrentBlockIndex(colorBlocks, 300)).toBe(2);
});
it("should return -1 for stitch before first block", () => {
expect(findCurrentBlockIndex(colorBlocks, -1)).toBe(-1);
});
it("should return -1 for stitch at or after last block end", () => {
expect(findCurrentBlockIndex(colorBlocks, 400)).toBe(-1);
expect(findCurrentBlockIndex(colorBlocks, 500)).toBe(-1);
});
it("should return -1 for empty color blocks array", () => {
expect(findCurrentBlockIndex([], 50)).toBe(-1);
});
it("should find block with single color block", () => {
const singleBlock = [colorBlocks[0]];
expect(findCurrentBlockIndex(singleBlock, 0)).toBe(0);
expect(findCurrentBlockIndex(singleBlock, 50)).toBe(0);
expect(findCurrentBlockIndex(singleBlock, 99)).toBe(0);
expect(findCurrentBlockIndex(singleBlock, 100)).toBe(-1);
});
});
});

View file

@ -0,0 +1,61 @@
/**
* Color Block Helpers
*
* Utility functions for calculating color block information from pattern data.
* Extracted from ProgressMonitor component for better testability and reusability.
*/
import type { PesPatternData } from "../formats/import/client";
import type { ColorBlock } from "../components/ProgressMonitor/types";
/**
* Calculate color blocks from decoded PEN pattern data
*
* Transforms PEN color blocks into enriched ColorBlock objects with thread metadata.
* Returns an empty array if pattern or penStitches data is unavailable.
*
* @param displayPattern - The PES pattern data containing penStitches and threads
* @returns Array of ColorBlock objects with thread information and stitch counts
*/
export function calculateColorBlocks(
displayPattern: PesPatternData | null,
): ColorBlock[] {
if (!displayPattern || !displayPattern.penStitches) return [];
const blocks: ColorBlock[] = [];
// Use the pre-computed color blocks from decoded PEN data
for (const penBlock of displayPattern.penStitches.colorBlocks) {
const thread = displayPattern.threads[penBlock.colorIndex];
blocks.push({
colorIndex: penBlock.colorIndex,
threadHex: thread?.hex || "#000000",
threadCatalogNumber: thread?.catalogNumber ?? null,
threadBrand: thread?.brand ?? null,
threadDescription: thread?.description ?? null,
threadChart: thread?.chart ?? null,
startStitch: penBlock.startStitchIndex,
endStitch: penBlock.endStitchIndex,
stitchCount: penBlock.endStitchIndex - penBlock.startStitchIndex,
});
}
return blocks;
}
/**
* Find the index of the color block containing a specific stitch
*
* @param colorBlocks - Array of color blocks to search
* @param currentStitch - The stitch index to find
* @returns The index of the containing block, or -1 if not found
*/
export function findCurrentBlockIndex(
colorBlocks: ColorBlock[],
currentStitch: number,
): number {
return colorBlocks.findIndex(
(block) =>
currentStitch >= block.startStitch && currentStitch < block.endStitch,
);
}

View file

@ -0,0 +1,22 @@
/**
* getDisplayFilename Utility
*
* Determines which filename to display based on priority:
* 1. currentFileName (from pattern store)
* 2. localFileName (from file input)
* 3. resumeFileName (from cache)
* 4. Empty string
*/
export function getDisplayFilename(options: {
currentFileName: string | null;
localFileName: string;
resumeFileName: string | null;
}): string {
return (
options.currentFileName ||
options.localFileName ||
options.resumeFileName ||
""
);
}

View file

@ -0,0 +1,82 @@
/**
* Format thread metadata for display.
*
* Combines brand, catalog number, chart, and description into a readable string
* using the following rules:
*
* - The primary part consists of the brand and catalog number:
* - The brand (if present) appears first.
* - The catalog number (if present) is prefixed with `#` and appended after
* the brand, separated by a single space (e.g. `"DMC #310"`).
* - The secondary part consists of the chart and description:
* - The chart is omitted if it is `null`/empty or exactly equal to
* `threadCatalogNumber`.
* - The chart (when shown) and the description are joined with a single
* space (e.g. `"Anchor 24-colour Black"`).
* - The primary and secondary parts are joined with `" • "` (space, bullet,
* space). If either part is empty, only the non-empty part is returned.
*
* Examples:
*
* - Brand and catalog only:
* - Input:
* - `threadBrand: "DMC"`
* - `threadCatalogNumber: "310"`
* - `threadChart: null`
* - `threadDescription: null`
* - Output: `"DMC #310"`
*
* - Brand, catalog, and description:
* - Input:
* - `threadBrand: "DMC"`
* - `threadCatalogNumber: "310"`
* - `threadChart: null`
* - `threadDescription: "Black"`
* - Output: `"DMC #310 • Black"`
*
* - Brand, catalog, chart (different from catalog), and description:
* - Input:
* - `threadBrand: "Anchor"`
* - `threadCatalogNumber: "403"`
* - `threadChart: "24-colour"`
* - `threadDescription: "Black"`
* - Output: `"Anchor #403 • 24-colour Black"`
*
* - Chart equal to catalog number (chart omitted):
* - Input:
* - `threadBrand: "DMC"`
* - `threadCatalogNumber: "310"`
* - `threadChart: "310"`
* - `threadDescription: "Black"`
* - Output: `"DMC #310 • Black"`
*/
interface ThreadMetadata {
threadBrand: string | null;
threadCatalogNumber: string | null;
threadChart: string | null;
threadDescription: string | null;
}
export function formatThreadMetadata(thread: ThreadMetadata): string {
// Primary metadata: brand and catalog number
const primaryMetadata = [
thread.threadBrand,
thread.threadCatalogNumber ? `#${thread.threadCatalogNumber}` : null,
]
.filter(Boolean)
.join(" ");
// Secondary metadata: chart and description
// Only show chart if it's different from catalogNumber
const secondaryMetadata = [
thread.threadChart && thread.threadChart !== thread.threadCatalogNumber
? thread.threadChart
: null,
thread.threadDescription,
]
.filter(Boolean)
.join(" ");
return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
}

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

View file

@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.{test,spec}.{js,ts}"],
environment: "jsdom",
include: ["src/**/*.{test,spec}.{js,ts,tsx}"],
},
});