Migrate to Tailwind CSS and fix canvas zoom issues

- Install Tailwind CSS and configure Vite plugin
- Replace all custom CSS classes with Tailwind utility classes
- Migrate all components to use Tailwind styling:
  - App.tsx: Responsive layout with modern styling
  - MachineConnection: Status badges and action buttons
  - FileUpload: File input and progress bars with shimmer effect
  - ProgressMonitor: Color blocks, state indicators, and actions
  - ConfirmDialog: Modal overlay with backdrop blur
  - PatternCanvas: Canvas viewer with floating controls
- Add custom shimmer animation for progress bars
- Fix canvas resizing issue during zoom operations:
  - Add ResizeObserver for stable container dimensions
  - Use clientWidth/clientHeight instead of offset dimensions
  - Cache container size to prevent layout thrashing
- Improve zoom button behavior to zoom towards viewport center
- Maintain consistent design with shadows, borders, and transitions

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik 2025-12-05 23:25:55 +01:00
parent f94aa071fb
commit 30d87f82bc
13 changed files with 950 additions and 1042 deletions

View file

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npm run build:*)"
],
"deny": [],
"ask": []
}
}

2
.gitignore vendored
View file

@ -22,3 +22,5 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
protocol/

752
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,9 +10,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.17",
"konva": "^10.0.12",
"pyodide": "^0.27.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"pyodide": "^0.27.4" "react-konva": "^19.2.1",
"tailwindcss": "^4.1.17"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

View file

@ -1,763 +1,6 @@
:root { @import "tailwindcss";
--primary-color: #0066cc;
--secondary-color: #6c757d;
--danger-color: #dc3545;
--success-color: #28a745;
--warning-color: #ffc107;
--info-color: #17a2b8;
--background: #f5f5f5;
--panel-background: #ffffff;
--border-color: #dee2e6;
--text-color: #212529;
--text-muted: #6c757d;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--background);
color: var(--text-color);
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
background-color: var(--panel-background);
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--border-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.app-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 0.75rem 1rem;
border-radius: 4px;
margin-top: 1rem;
border: 1px solid #f5c6cb;
}
.app-content {
flex: 1;
display: grid;
grid-template-columns: 400px 1fr;
gap: 1.5rem;
padding: 1.5rem;
max-width: 1600px;
width: 100%;
margin: 0 auto;
}
.left-panel {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.right-panel {
display: flex;
flex-direction: column;
}
.connection-panel,
.file-upload-panel,
.progress-panel,
.canvas-panel {
background-color: var(--panel-background);
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border-color);
}
h3 {
font-size: 1rem;
font-weight: 600;
margin: 1rem 0 0.5rem 0;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-row .label {
font-weight: 500;
color: var(--text-muted);
}
.detail-row .value {
font-weight: 600;
}
.status-bar {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.75rem;
background-color: var(--background);
border-radius: 4px;
}
.status-indicator {
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
}
.status-indicator.status-16 { /* IDLE */
background-color: #d1ecf1;
color: #0c5460;
}
.status-indicator.status-17 { /* SEWING_WAIT */
background-color: #d4edda;
color: #155724;
}
.status-indicator.status-48 { /* SEWING */
background-color: #fff3cd;
color: #856404;
}
.status-indicator.status-49 { /* SEWING_COMPLETE */
background-color: #d4edda;
color: #155724;
}
.status-indicator.status-64 { /* COLOR_CHANGE_WAIT */
background-color: #fff3cd;
color: #856404;
}
.error-indicator {
background-color: #f8d7da;
color: #721c24;
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.875rem;
}
.polling-indicator {
color: var(--primary-color);
font-size: 0.75rem;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.connection-actions,
.progress-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
flex-wrap: wrap;
}
button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #0052a3;
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #5a6268;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-danger:hover:not(:disabled) {
background-color: #c82333;
}
.file-input {
display: none;
}
.progress-bar {
height: 8px;
background-color: var(--border-color);
border-radius: 4px;
overflow: hidden;
margin: 1rem 0;
}
.progress-fill {
height: 100%;
background-color: var(--primary-color);
transition: width 0.3s;
}
/* Canvas container with Konva */
.canvas-container {
position: relative;
width: 100%;
height: 600px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: #fafafa;
overflow: hidden;
}
.canvas-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 600px;
color: var(--text-muted);
font-style: italic;
}
/* Canvas overlay elements */
.canvas-legend {
position: absolute;
top: 10px;
left: 10px;
background: rgba(255, 255, 255, 0.95);
padding: 12px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 10;
backdrop-filter: blur(4px);
max-width: 150px;
}
.canvas-legend h4 {
margin: 0 0 8px 0;
font-size: 13px;
font-weight: 600;
color: var(--text-color);
border-bottom: 1px solid var(--border-color);
padding-bottom: 6px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.legend-item:last-child {
margin-bottom: 0;
}
.legend-swatch {
width: 20px;
height: 20px;
border-radius: 3px;
border: 1px solid #000;
flex-shrink: 0;
}
.legend-label {
font-size: 12px;
color: var(--text-color);
}
.canvas-dimensions {
position: absolute;
bottom: 165px;
right: 20px;
background: rgba(255, 255, 255, 0.95);
padding: 8px 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 11;
backdrop-filter: blur(4px);
font-size: 14px;
font-weight: 600;
color: var(--text-color);
}
.canvas-offset-info {
position: absolute;
bottom: 80px;
right: 20px;
background: rgba(255, 255, 255, 0.95);
padding: 10px 14px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 11;
backdrop-filter: blur(4px);
min-width: 180px;
}
.offset-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.offset-value {
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
margin-bottom: 4px;
}
.offset-hint {
font-size: 10px;
color: var(--text-muted);
font-style: italic;
}
/* Zoom controls */
.zoom-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
gap: 8px;
align-items: center;
background: rgba(255, 255, 255, 0.95);
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 10;
backdrop-filter: blur(4px);
}
.zoom-btn {
width: 32px;
height: 32px;
padding: 0;
font-size: 18px;
font-weight: bold;
border: 1px solid var(--border-color);
background: white;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.zoom-btn:hover:not(:disabled) {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 102, 204, 0.3);
}
.zoom-btn:active:not(:disabled) {
transform: translateY(0);
}
.zoom-level {
min-width: 50px;
text-align: center;
font-size: 13px;
font-weight: 600;
color: var(--text-color);
user-select: none;
}
.zoom-reset {
margin-left: 4px;
font-size: 20px;
}
.status-message {
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
font-weight: 500;
}
.status-message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-message.warning {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeeba;
}
.status-message.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
/* Color Block Progress Styles */
.color-blocks {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.color-block-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.color-block-item {
padding: 0.75rem;
border-radius: 4px;
background-color: var(--background);
border: 2px solid transparent;
transition: all 0.3s;
}
.color-block-item.completed {
border-color: var(--success-color);
background-color: #f0f9f4;
}
.color-block-item.current {
border-color: var(--primary-color);
background-color: #e7f3ff;
box-shadow: 0 2px 8px rgba(0, 102, 204, 0.2);
}
.color-block-item.pending {
opacity: 0.6;
}
.block-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.color-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 2px solid var(--border-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.block-label {
font-weight: 600;
flex: 1;
}
.block-status {
font-size: 1.25rem;
font-weight: bold;
color: var(--text-muted);
}
.color-block-item.completed .block-status {
color: var(--success-color);
}
.color-block-item.current .block-status {
color: var(--primary-color);
}
.block-stitches {
font-size: 0.875rem;
color: var(--text-muted);
}
.block-progress-bar {
margin-top: 0.5rem;
height: 4px;
background-color: #fff;
border-radius: 2px;
overflow: hidden;
}
.block-progress-fill {
height: 100%;
background-color: var(--primary-color);
transition: width 0.3s;
}
@media (max-width: 1024px) {
.app-content {
grid-template-columns: 1fr;
}
}
/* ==========================================================================
State-Based UX Safety Styles
========================================================================== */
/* Confirmation Dialog Styles */
.confirm-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog {
background-color: var(--panel-background);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
margin: 1rem;
}
.confirm-dialog-danger {
border-top: 4px solid #dc3545;
}
.confirm-dialog-warning {
border-top: 4px solid #ffc107;
}
.confirm-dialog-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.confirm-dialog-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.confirm-dialog-body {
padding: 1.5rem;
}
.confirm-dialog-body p {
margin: 0;
line-height: 1.5;
color: var(--text-color);
}
.confirm-dialog-actions {
padding: 1rem 1.5rem;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
border-top: 1px solid var(--border-color);
}
/* Enhanced Status Badge */
.status-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.875rem;
}
.status-badge-idle,
.status-badge-info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.status-badge-active,
.status-badge-waiting,
.status-badge-warning {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeeba;
}
.status-badge-complete,
.status-badge-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-badge-interrupted,
.status-badge-error,
.status-badge-danger {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status-icon {
font-size: 1.1rem;
line-height: 1;
}
.status-text {
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* State Indicator Component */
.state-indicator {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: 8px;
margin: 1rem 0;
border-left: 4px solid transparent;
}
.state-indicator-idle,
.state-indicator-info {
background-color: #e7f3ff;
border-left-color: #0066cc;
}
.state-indicator-active,
.state-indicator-waiting,
.state-indicator-warning {
background-color: #fff8e1;
border-left-color: #ffc107;
}
.state-indicator-complete,
.state-indicator-success {
background-color: #e8f5e9;
border-left-color: #28a745;
}
.state-indicator-interrupted,
.state-indicator-error,
.state-indicator-danger {
background-color: #ffebee;
border-left-color: #dc3545;
}
.state-icon {
font-size: 2rem;
line-height: 1;
}
.state-info {
flex: 1;
}
.state-label {
font-weight: 600;
font-size: 1rem;
margin-bottom: 0.25rem;
}
.state-description {
font-size: 0.875rem;
color: #666;
}
/* Enhanced Progress Visualization */
.progress-bar {
height: 12px;
background-color: var(--border-color);
border-radius: 6px;
overflow: hidden;
margin: 1rem 0;
position: relative;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary-color), #0052a3);
transition: width 0.3s ease;
position: relative;
overflow: hidden;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: shimmer 2s infinite;
}
/* Custom animations for shimmer effect */
@keyframes shimmer { @keyframes shimmer {
0% { 0% {
transform: translateX(-100%); transform: translateX(-100%);
@ -766,38 +9,3 @@ button:disabled {
transform: translateX(100%); transform: translateX(100%);
} }
} }
/* Button enhancements for safety */
button.btn-danger:hover:not(:disabled) {
background-color: #c82333;
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3);
transform: translateY(-1px);
}
button.btn-primary:hover:not(:disabled) {
box-shadow: 0 2px 8px rgba(0, 102, 204, 0.3);
transform: translateY(-1px);
}
button.btn-secondary:hover:not(:disabled) {
box-shadow: 0 2px 8px rgba(108, 117, 125, 0.3);
transform: translateY(-1px);
}
button:active:not(:disabled) {
transform: translateY(0);
}
/* Info message styles */
.status-message.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
/* Enhanced visual feedback for disabled state */
button:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(0.3);
}

View file

@ -49,22 +49,22 @@ function App() {
}, []); }, []);
return ( return (
<div className="app"> <div className="min-h-screen flex flex-col">
<header className="app-header"> <header className="bg-white px-8 py-6 border-b border-gray-300 shadow-md">
<h1>Brother Embroidery Machine Controller</h1> <h1 className="text-3xl font-semibold mb-2">Brother Embroidery Machine Controller</h1>
{machine.error && ( {machine.error && (
<div className="error-message">{machine.error}</div> <div className="bg-red-100 text-red-900 px-4 py-3 rounded border border-red-200 mt-4">{machine.error}</div>
)} )}
{pyodideError && ( {pyodideError && (
<div className="error-message">Python Error: {pyodideError}</div> <div className="bg-red-100 text-red-900 px-4 py-3 rounded border border-red-200 mt-4">Python Error: {pyodideError}</div>
)} )}
{!pyodideReady && !pyodideError && ( {!pyodideReady && !pyodideError && (
<div className="info-message">Initializing Python environment...</div> <div className="bg-blue-100 text-blue-900 px-4 py-3 rounded border border-blue-200 mt-4">Initializing Python environment...</div>
)} )}
</header> </header>
<div className="app-content"> <div className="flex-1 grid grid-cols-1 lg:grid-cols-[400px_1fr] gap-6 p-6 max-w-[1600px] w-full mx-auto">
<div className="left-panel"> <div className="flex flex-col gap-6">
<MachineConnection <MachineConnection
isConnected={machine.isConnected} isConnected={machine.isConnected}
machineInfo={machine.machineInfo} machineInfo={machine.machineInfo}
@ -101,7 +101,7 @@ function App() {
/> />
</div> </div>
<div className="right-panel"> <div className="flex flex-col">
<PatternCanvas <PatternCanvas
pesData={pesData} pesData={pesData}
sewingProgress={machine.sewingProgress} sewingProgress={machine.sewingProgress}

View file

@ -38,31 +38,31 @@ export function ConfirmDialog({
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="confirm-dialog-overlay" onClick={onCancel}> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[1000]" onClick={onCancel}>
<div <div
className={`confirm-dialog confirm-dialog-${variant}`} className={`bg-white rounded-lg shadow-2xl max-w-lg w-[90%] m-4 ${variant === 'danger' ? 'border-t-4 border-red-600' : 'border-t-4 border-yellow-500'}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
role="dialog" role="dialog"
aria-labelledby="dialog-title" aria-labelledby="dialog-title"
aria-describedby="dialog-message" aria-describedby="dialog-message"
> >
<div className="confirm-dialog-header"> <div className="p-6 border-b border-gray-300">
<h3 id="dialog-title">{title}</h3> <h3 id="dialog-title" className="m-0 text-xl font-semibold">{title}</h3>
</div> </div>
<div className="confirm-dialog-body"> <div className="p-6">
<p id="dialog-message">{message}</p> <p id="dialog-message" className="m-0 leading-relaxed text-gray-900">{message}</p>
</div> </div>
<div className="confirm-dialog-actions"> <div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300">
<button <button
onClick={onCancel} onClick={onCancel}
className="btn-secondary" className="px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm hover:bg-gray-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]"
autoFocus autoFocus
> >
{cancelText} {cancelText}
</button> </button>
<button <button
onClick={onConfirm} onClick={onConfirm}
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'} className={variant === 'danger' ? 'px-6 py-3 bg-red-600 text-white rounded font-semibold text-sm hover:bg-red-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]' : 'px-6 py-3 bg-blue-600 text-white rounded font-semibold text-sm hover:bg-blue-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]'}
> >
{confirmText} {confirmText}
</button> </button>

View file

@ -62,43 +62,43 @@ export function FileUpload({
}, [pesData, fileName, onUpload, patternOffset]); }, [pesData, fileName, onUpload, patternOffset]);
return ( return (
<div className="file-upload-panel"> <div className="bg-white p-6 rounded-lg shadow-md">
<h2>Pattern File</h2> <h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300">Pattern File</h2>
<div className="upload-controls"> <div>
<input <input
type="file" type="file"
accept=".pes" accept=".pes"
onChange={handleFileChange} onChange={handleFileChange}
id="file-input" id="file-input"
className="file-input" className="hidden"
disabled={!pyodideReady || isLoading} disabled={!pyodideReady || isLoading}
/> />
<label htmlFor="file-input" className={`btn-secondary ${!pyodideReady || isLoading ? 'disabled' : ''}`}> <label htmlFor="file-input" className={`inline-block px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm cursor-pointer transition-all ${!pyodideReady || isLoading ? 'opacity-50 cursor-not-allowed grayscale-[0.3]' : 'hover:bg-gray-700 hover:shadow-md hover:-translate-y-0.5 active:translate-y-0'}`}>
{isLoading ? 'Loading...' : !pyodideReady ? 'Initializing...' : 'Choose PES File'} {isLoading ? 'Loading...' : !pyodideReady ? 'Initializing...' : 'Choose PES File'}
</label> </label>
{pesData && ( {pesData && (
<div className="pattern-info"> <div className="mt-4">
<h3>Pattern Details</h3> <h3 className="text-base font-semibold my-4">Pattern Details</h3>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Total Stitches:</span> <span className="font-medium text-gray-600">Total Stitches:</span>
<span className="value">{pesData.stitchCount}</span> <span className="font-semibold">{pesData.stitchCount}</span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Colors:</span> <span className="font-medium text-gray-600">Colors:</span>
<span className="value">{pesData.colorCount}</span> <span className="font-semibold">{pesData.colorCount}</span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Size:</span> <span className="font-medium text-gray-600">Size:</span>
<span className="value"> <span className="font-semibold">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '} {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</span> </span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2">
<span className="label">Bounds:</span> <span className="font-medium text-gray-600">Bounds:</span>
<span className="value"> <span className="font-semibold">
({pesData.bounds.minX}, {pesData.bounds.minY}) to ( ({pesData.bounds.minX}, {pesData.bounds.minY}) to (
{pesData.bounds.maxX}, {pesData.bounds.maxY}) {pesData.bounds.maxX}, {pesData.bounds.maxY})
</span> </span>
@ -110,7 +110,7 @@ export function FileUpload({
<button <button
onClick={handleUpload} onClick={handleUpload}
disabled={!isConnected || uploadProgress > 0} disabled={!isConnected || uploadProgress > 0}
className="btn-primary" className="mt-4 px-6 py-3 bg-blue-600 text-white rounded font-semibold text-sm hover:bg-blue-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]"
> >
{uploadProgress > 0 {uploadProgress > 0
? `Uploading... ${uploadProgress.toFixed(0)}%` ? `Uploading... ${uploadProgress.toFixed(0)}%`
@ -119,15 +119,15 @@ export function FileUpload({
)} )}
{pesData && !canUploadPattern(machineStatus) && ( {pesData && !canUploadPattern(machineStatus) && (
<div className="status-message warning"> <div className="bg-yellow-100 text-yellow-800 px-4 py-3 rounded border border-yellow-200 my-4 font-medium">
Cannot upload pattern while machine is {getMachineStateCategory(machineStatus)} Cannot upload pattern while machine is {getMachineStateCategory(machineStatus)}
</div> </div>
)} )}
{uploadProgress > 0 && uploadProgress < 100 && ( {uploadProgress > 0 && uploadProgress < 100 && (
<div className="progress-bar"> <div className="h-3 bg-gray-300 rounded-md overflow-hidden my-4 shadow-inner">
<div <div
className="progress-fill" className="h-full bg-gradient-to-r from-blue-600 to-blue-700 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]"
style={{ width: `${uploadProgress}%` }} style={{ width: `${uploadProgress}%` }}
/> />
</div> </div>

View file

@ -49,70 +49,83 @@ export function MachineConnection({
const stateVisual = getStateVisualInfo(machineStatus); const stateVisual = getStateVisualInfo(machineStatus);
const statusBadgeColors = {
idle: 'bg-cyan-100 text-cyan-800 border-cyan-200',
info: 'bg-cyan-100 text-cyan-800 border-cyan-200',
active: 'bg-yellow-100 text-yellow-800 border-yellow-200',
waiting: 'bg-yellow-100 text-yellow-800 border-yellow-200',
warning: 'bg-yellow-100 text-yellow-800 border-yellow-200',
complete: 'bg-green-100 text-green-800 border-green-200',
success: 'bg-green-100 text-green-800 border-green-200',
interrupted: 'bg-red-100 text-red-800 border-red-200',
error: 'bg-red-100 text-red-800 border-red-200',
danger: 'bg-red-100 text-red-800 border-red-200',
};
return ( return (
<div className="connection-panel"> <div className="bg-white p-6 rounded-lg shadow-md">
<h2>Machine Connection</h2> <h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300">Machine Connection</h2>
{!isConnected ? ( {!isConnected ? (
<div className="connection-actions"> <div className="flex gap-3 mt-4 flex-wrap">
<button onClick={onConnect} className="btn-primary"> <button onClick={onConnect} className="px-6 py-3 bg-blue-600 text-white rounded font-semibold text-sm hover:bg-blue-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
Connect to Machine Connect to Machine
</button> </button>
</div> </div>
) : ( ) : (
<div className="connection-info"> <div>
<div className="status-bar"> <div className="flex items-center gap-4 mb-4 p-3 bg-gray-100 rounded">
<span className={`status-badge status-badge-${stateVisual.color}`}> <span className={`flex items-center gap-2 px-4 py-2 rounded font-semibold text-sm border ${statusBadgeColors[stateVisual.color as keyof typeof statusBadgeColors] || statusBadgeColors.info}`}>
<span className="status-icon">{stateVisual.icon}</span> <span className="text-lg leading-none">{stateVisual.icon}</span>
<span className="status-text">{machineStatusName}</span> <span className="uppercase tracking-wide">{machineStatusName}</span>
</span> </span>
{isPolling && ( {isPolling && (
<span className="polling-indicator" title="Polling machine status"></span> <span className="text-blue-600 text-xs animate-pulse" title="Polling machine status"></span>
)} )}
{hasError(machineError) && ( {hasError(machineError) && (
<span className="error-indicator">{getErrorMessage(machineError)}</span> <span className="bg-red-100 text-red-900 px-4 py-2 rounded font-semibold text-sm">{getErrorMessage(machineError)}</span>
)} )}
</div> </div>
{machineInfo && ( {machineInfo && (
<div className="machine-details"> <div>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Model:</span> <span className="font-medium text-gray-600">Model:</span>
<span className="value">{machineInfo.modelNumber}</span> <span className="font-semibold">{machineInfo.modelNumber}</span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Serial:</span> <span className="font-medium text-gray-600">Serial:</span>
<span className="value">{machineInfo.serialNumber}</span> <span className="font-semibold">{machineInfo.serialNumber}</span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Software:</span> <span className="font-medium text-gray-600">Software:</span>
<span className="value">{machineInfo.softwareVersion}</span> <span className="font-semibold">{machineInfo.softwareVersion}</span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Max Area:</span> <span className="font-medium text-gray-600">Max Area:</span>
<span className="value"> <span className="font-semibold">
{(machineInfo.maxWidth / 10).toFixed(1)} x{' '} {(machineInfo.maxWidth / 10).toFixed(1)} x{' '}
{(machineInfo.maxHeight / 10).toFixed(1)} mm {(machineInfo.maxHeight / 10).toFixed(1)} mm
</span> </span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2">
<span className="label">MAC:</span> <span className="font-medium text-gray-600">MAC:</span>
<span className="value">{machineInfo.macAddress}</span> <span className="font-semibold">{machineInfo.macAddress}</span>
</div> </div>
</div> </div>
)} )}
{resumeAvailable && resumeFileName && ( {resumeAvailable && resumeFileName && (
<div className="status-message success"> <div className="bg-green-100 text-green-800 px-4 py-3 rounded border border-green-200 my-4 font-medium">
Loaded cached pattern: "{resumeFileName}" Loaded cached pattern: "{resumeFileName}"
</div> </div>
)} )}
<div className="connection-actions"> <div className="flex gap-3 mt-4 flex-wrap">
<button onClick={onRefresh} className="btn-secondary"> <button onClick={onRefresh} className="px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm hover:bg-gray-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
Refresh Status Refresh Status
</button> </button>
<button onClick={handleDisconnectClick} className="btn-danger"> <button onClick={handleDisconnectClick} className="px-6 py-3 bg-red-600 text-white rounded font-semibold text-sm hover:bg-red-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
Disconnect Disconnect
</button> </button>
</div> </div>

View file

@ -28,26 +28,46 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
const [stagePos, setStagePos] = useState({ x: 0, y: 0 }); const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
const [stageScale, setStageScale] = useState(1); const [stageScale, setStageScale] = useState(1);
const [patternOffset, setPatternOffset] = useState({ x: 0, y: 0 }); const [patternOffset, setPatternOffset] = useState({ x: 0, y: 0 });
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const initialScaleRef = useRef<number>(1); const initialScaleRef = useRef<number>(1);
// Track container size
useEffect(() => {
if (!containerRef.current) return;
const updateSize = () => {
if (containerRef.current) {
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
setContainerSize({ width, height });
}
};
// Initial size
updateSize();
// Watch for resize
const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, []);
// Calculate initial scale when pattern or hoop changes // Calculate initial scale when pattern or hoop changes
useEffect(() => { useEffect(() => {
if (!pesData || !containerRef.current) return; if (!pesData || containerSize.width === 0) return;
const { bounds } = pesData; const { bounds } = pesData;
const viewWidth = machineInfo ? machineInfo.maxWidth : bounds.maxX - bounds.minX; const viewWidth = machineInfo ? machineInfo.maxWidth : bounds.maxX - bounds.minX;
const viewHeight = machineInfo ? machineInfo.maxHeight : bounds.maxY - bounds.minY; const viewHeight = machineInfo ? machineInfo.maxHeight : bounds.maxY - bounds.minY;
const width = containerRef.current.offsetWidth; const initialScale = calculateInitialScale(containerSize.width, containerSize.height, viewWidth, viewHeight);
const height = containerRef.current.offsetHeight;
const initialScale = calculateInitialScale(width, height, viewWidth, viewHeight);
initialScaleRef.current = initialScale; initialScaleRef.current = initialScale;
// Set initial scale and center position when pattern loads // Set initial scale and center position when pattern loads
setStageScale(initialScale); setStageScale(initialScale);
setStagePos({ x: width / 2, y: height / 2 }); setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
}, [pesData, machineInfo]); }, [pesData, machineInfo, containerSize]);
// Wheel zoom handler // Wheel zoom handler
const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => { const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
@ -84,21 +104,54 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
// Zoom control handlers // Zoom control handlers
const handleZoomIn = useCallback(() => { const handleZoomIn = useCallback(() => {
const newScale = Math.min(stageScale * 1.2, 10); const oldScale = stageScale;
const newScale = Math.min(oldScale * 1.2, 10);
// Zoom towards center of viewport
const centerX = containerSize.width / 2;
const centerY = containerSize.height / 2;
const mousePointTo = {
x: (centerX - stagePos.x) / oldScale,
y: (centerY - stagePos.y) / oldScale,
};
const newPos = {
x: centerX - mousePointTo.x * newScale,
y: centerY - mousePointTo.y * newScale,
};
setStageScale(newScale); setStageScale(newScale);
}, [stageScale]); setStagePos(newPos);
}, [stageScale, stagePos, containerSize]);
const handleZoomOut = useCallback(() => { const handleZoomOut = useCallback(() => {
const newScale = Math.max(stageScale / 1.2, 0.1); const oldScale = stageScale;
const newScale = Math.max(oldScale / 1.2, 0.1);
// Zoom towards center of viewport
const centerX = containerSize.width / 2;
const centerY = containerSize.height / 2;
const mousePointTo = {
x: (centerX - stagePos.x) / oldScale,
y: (centerY - stagePos.y) / oldScale,
};
const newPos = {
x: centerX - mousePointTo.x * newScale,
y: centerY - mousePointTo.y * newScale,
};
setStageScale(newScale); setStageScale(newScale);
}, [stageScale]); setStagePos(newPos);
}, [stageScale, stagePos, containerSize]);
const handleZoomReset = useCallback(() => { const handleZoomReset = useCallback(() => {
if (!containerRef.current) return;
const initialScale = initialScaleRef.current; const initialScale = initialScaleRef.current;
setStageScale(initialScale); setStageScale(initialScale);
setStagePos({ x: containerRef.current.offsetWidth / 2, y: containerRef.current.offsetHeight / 2 }); setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
}, []); }, [containerSize]);
// Pattern drag handlers // Pattern drag handlers
const handlePatternDragEnd = useCallback((e: Konva.KonvaEventObject<DragEvent>) => { const handlePatternDragEnd = useCallback((e: Konva.KonvaEventObject<DragEvent>) => {
@ -169,13 +222,13 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
}, [renderPatternLayer]); }, [renderPatternLayer]);
return ( return (
<div className="canvas-panel"> <div className="bg-white p-6 rounded-lg shadow-md">
<h2>Pattern Preview</h2> <h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300">Pattern Preview</h2>
<div className="canvas-container" ref={containerRef} style={{ width: '100%', height: '600px' }}> <div className="relative w-full h-[600px] border border-gray-300 rounded bg-gray-50 overflow-hidden" ref={containerRef}>
{containerRef.current && ( {containerSize.width > 0 && (
<Stage <Stage
width={containerRef.current.offsetWidth} width={containerSize.width}
height={containerRef.current.offsetHeight} height={containerSize.height}
x={stagePos.x} x={stagePos.x}
y={stagePos.y} y={stagePos.y}
scaleX={stageScale} scaleX={stageScale}
@ -235,7 +288,7 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
{/* Placeholder overlay when no pattern is loaded */} {/* Placeholder overlay when no pattern is loaded */}
{!pesData && ( {!pesData && (
<div className="canvas-placeholder"> <div className="flex items-center justify-center h-[600px] text-gray-600 italic">
Load a PES file to preview the pattern Load a PES file to preview the pattern
</div> </div>
)} )}
@ -244,46 +297,46 @@ export function PatternCanvas({ pesData, sewingProgress, machineInfo, onPatternO
{pesData && ( {pesData && (
<> <>
{/* Thread Legend Overlay */} {/* Thread Legend Overlay */}
<div className="canvas-legend"> <div className="absolute top-2.5 left-2.5 bg-white/95 backdrop-blur-sm p-3 rounded-lg shadow-lg z-10 max-w-[150px]">
<h4>Threads</h4> <h4 className="m-0 mb-2 text-[13px] font-semibold text-gray-900 border-b border-gray-300 pb-1.5">Threads</h4>
{pesData.threads.map((thread, index) => ( {pesData.threads.map((thread, index) => (
<div key={index} className="legend-item"> <div key={index} className="flex items-center gap-2 mb-1.5 last:mb-0">
<div <div
className="legend-swatch" className="w-5 h-5 rounded border border-black flex-shrink-0"
style={{ backgroundColor: thread.hex }} style={{ backgroundColor: thread.hex }}
/> />
<span className="legend-label">Thread {index + 1}</span> <span className="text-xs text-gray-900">Thread {index + 1}</span>
</div> </div>
))} ))}
</div> </div>
{/* Pattern Dimensions Overlay */} {/* Pattern Dimensions Overlay */}
<div className="canvas-dimensions"> <div className="absolute bottom-[165px] right-5 bg-white/95 backdrop-blur-sm px-4 py-2 rounded-lg shadow-lg z-[11] text-sm font-semibold text-gray-900">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '} {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</div> </div>
{/* Pattern Offset Indicator */} {/* Pattern Offset Indicator */}
<div className="canvas-offset-info"> <div className="absolute bottom-20 right-5 bg-white/95 backdrop-blur-sm p-2.5 px-3.5 rounded-lg shadow-lg z-[11] min-w-[180px]">
<div className="offset-label">Pattern Position:</div> <div className="text-[11px] font-semibold text-gray-600 uppercase tracking-wider mb-1">Pattern Position:</div>
<div className="offset-value"> <div className="text-[13px] font-semibold text-blue-600 mb-1">
X: {(patternOffset.x / 10).toFixed(1)}mm, Y: {(patternOffset.y / 10).toFixed(1)}mm X: {(patternOffset.x / 10).toFixed(1)}mm, Y: {(patternOffset.y / 10).toFixed(1)}mm
</div> </div>
<div className="offset-hint"> <div className="text-[10px] text-gray-600 italic">
Drag pattern to move Drag background to pan Drag pattern to move Drag background to pan
</div> </div>
</div> </div>
{/* Zoom Controls Overlay */} {/* Zoom Controls Overlay */}
<div className="zoom-controls"> <div className="absolute bottom-5 right-5 flex gap-2 items-center bg-white/95 backdrop-blur-sm px-3 py-2 rounded-lg shadow-lg z-10">
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom In"> <button className="w-8 h-8 p-0 text-lg font-bold border border-gray-300 bg-white rounded cursor-pointer transition-all flex items-center justify-center hover:bg-blue-600 hover:text-white hover:border-blue-600 hover:-translate-y-0.5 hover:shadow-md hover:shadow-blue-600/30 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomIn} title="Zoom In">
+ +
</button> </button>
<span className="zoom-level">{Math.round(stageScale * 100)}%</span> <span className="min-w-[50px] text-center text-[13px] font-semibold text-gray-900 select-none">{Math.round(stageScale * 100)}%</span>
<button className="zoom-btn" onClick={handleZoomOut} title="Zoom Out"> <button className="w-8 h-8 p-0 text-lg font-bold border border-gray-300 bg-white rounded cursor-pointer transition-all flex items-center justify-center hover:bg-blue-600 hover:text-white hover:border-blue-600 hover:-translate-y-0.5 hover:shadow-md hover:shadow-blue-600/30 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomOut} title="Zoom Out">
</button> </button>
<button className="zoom-btn zoom-reset" onClick={handleZoomReset} title="Reset Zoom"> <button className="w-8 h-8 p-0 text-xl font-bold border border-gray-300 bg-white rounded cursor-pointer transition-all flex items-center justify-center hover:bg-blue-600 hover:text-white hover:border-blue-600 hover:-translate-y-0.5 hover:shadow-md hover:shadow-blue-600/30 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed ml-1" onClick={handleZoomReset} title="Reset Zoom">
</button> </button>
</div> </div>

View file

@ -85,30 +85,43 @@ export function ProgressMonitor({
block => currentStitch >= block.startStitch && currentStitch < block.endStitch block => currentStitch >= block.startStitch && currentStitch < block.endStitch
); );
const stateIndicatorColors = {
idle: 'bg-blue-50 border-l-blue-600',
info: 'bg-blue-50 border-l-blue-600',
active: 'bg-yellow-50 border-l-yellow-500',
waiting: 'bg-yellow-50 border-l-yellow-500',
warning: 'bg-yellow-50 border-l-yellow-500',
complete: 'bg-green-50 border-l-green-600',
success: 'bg-green-50 border-l-green-600',
interrupted: 'bg-red-50 border-l-red-600',
error: 'bg-red-50 border-l-red-600',
danger: 'bg-red-50 border-l-red-600',
};
return ( return (
<div className="progress-panel"> <div className="bg-white p-6 rounded-lg shadow-md">
<h2>Sewing Progress</h2> <h2 className="text-xl font-semibold mb-4 pb-2 border-b-2 border-gray-300">Sewing Progress</h2>
{patternInfo && ( {patternInfo && (
<div className="pattern-stats"> <div>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Total Stitches:</span> <span className="font-medium text-gray-600">Total Stitches:</span>
<span className="value">{patternInfo.totalStitches}</span> <span className="font-semibold">{patternInfo.totalStitches}</span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Estimated Time:</span> <span className="font-medium text-gray-600">Estimated Time:</span>
<span className="value"> <span className="font-semibold">
{Math.floor(patternInfo.totalTime / 60)}: {Math.floor(patternInfo.totalTime / 60)}:
{(patternInfo.totalTime % 60).toString().padStart(2, '0')} {(patternInfo.totalTime % 60).toString().padStart(2, '0')}
</span> </span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Speed:</span> <span className="font-medium text-gray-600">Speed:</span>
<span className="value">{patternInfo.speed} spm</span> <span className="font-semibold">{patternInfo.speed} spm</span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2">
<span className="label">Bounds:</span> <span className="font-medium text-gray-600">Bounds:</span>
<span className="value"> <span className="font-semibold">
({patternInfo.boundLeft}, {patternInfo.boundTop}) to ( ({patternInfo.boundLeft}, {patternInfo.boundTop}) to (
{patternInfo.boundRight}, {patternInfo.boundBottom}) {patternInfo.boundRight}, {patternInfo.boundBottom})
</span> </span>
@ -117,13 +130,12 @@ export function ProgressMonitor({
)} )}
{colorBlocks.length > 0 && ( {colorBlocks.length > 0 && (
<div className="color-blocks"> <div className="mt-6 pt-4 border-t border-gray-300">
<h3>Color Blocks</h3> <h3 className="text-base font-semibold my-4">Color Blocks</h3>
<div className="color-block-list"> <div className="flex flex-col gap-2">
{colorBlocks.map((block, index) => { {colorBlocks.map((block, index) => {
const isCompleted = currentStitch >= block.endStitch; const isCompleted = currentStitch >= block.endStitch;
const isCurrent = index === currentBlockIndex; const isCurrent = index === currentBlockIndex;
const isPending = currentStitch < block.startStitch;
// Calculate progress within current block // Calculate progress within current block
let blockProgress = 0; let blockProgress = 0;
@ -136,30 +148,30 @@ export function ProgressMonitor({
return ( return (
<div <div
key={index} key={index}
className={`color-block-item ${ className={`p-3 rounded bg-gray-100 border-2 border-transparent transition-all ${
isCompleted ? 'completed' : isCurrent ? 'current' : 'pending' isCompleted ? 'border-green-600 bg-green-50' : isCurrent ? 'border-blue-600 bg-blue-50 shadow-md shadow-blue-600/20' : 'opacity-60'
}`} }`}
> >
<div className="block-header"> <div className="flex items-center gap-3">
<div <div
className="color-swatch" className="w-6 h-6 rounded border-2 border-gray-300 shadow-sm flex-shrink-0"
style={{ backgroundColor: block.threadHex }} style={{ backgroundColor: block.threadHex }}
title={block.threadHex} title={block.threadHex}
/> />
<span className="block-label"> <span className="font-semibold flex-1">
Thread {block.colorIndex + 1} Thread {block.colorIndex + 1}
</span> </span>
<span className="block-status"> <span className={`text-xl font-bold ${isCompleted ? 'text-green-600' : isCurrent ? 'text-blue-600' : 'text-gray-600'}`}>
{isCompleted ? '✓' : isCurrent ? '→' : '○'} {isCompleted ? '✓' : isCurrent ? '→' : '○'}
</span> </span>
<span className="block-stitches"> <span className="text-sm text-gray-600">
{block.stitchCount} stitches {block.stitchCount} stitches
</span> </span>
</div> </div>
{isCurrent && ( {isCurrent && (
<div className="block-progress-bar"> <div className="mt-2 h-1 bg-white rounded overflow-hidden">
<div <div
className="block-progress-fill" className="h-full bg-blue-600 transition-all duration-300"
style={{ width: `${blockProgress}%` }} style={{ width: `${blockProgress}%` }}
/> />
</div> </div>
@ -172,60 +184,60 @@ export function ProgressMonitor({
)} )}
{sewingProgress && ( {sewingProgress && (
<div className="sewing-stats"> <div className="mt-4">
<div className="progress-bar"> <div className="h-3 bg-gray-300 rounded-md overflow-hidden my-4 shadow-inner relative">
<div className="progress-fill" style={{ width: `${progressPercent}%` }} /> <div className="h-full bg-gradient-to-r from-blue-600 to-blue-700 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite]" style={{ width: `${progressPercent}%` }} />
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Current Stitch:</span> <span className="font-medium text-gray-600">Current Stitch:</span>
<span className="value"> <span className="font-semibold">
{sewingProgress.currentStitch} / {patternInfo?.totalStitches || 0} {sewingProgress.currentStitch} / {patternInfo?.totalStitches || 0}
</span> </span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Elapsed Time:</span> <span className="font-medium text-gray-600">Elapsed Time:</span>
<span className="value"> <span className="font-semibold">
{Math.floor(sewingProgress.currentTime / 60)}: {Math.floor(sewingProgress.currentTime / 60)}:
{(sewingProgress.currentTime % 60).toString().padStart(2, '0')} {(sewingProgress.currentTime % 60).toString().padStart(2, '0')}
</span> </span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2 border-b border-gray-300">
<span className="label">Position:</span> <span className="font-medium text-gray-600">Position:</span>
<span className="value"> <span className="font-semibold">
({(sewingProgress.positionX / 10).toFixed(1)}mm,{' '} ({(sewingProgress.positionX / 10).toFixed(1)}mm,{' '}
{(sewingProgress.positionY / 10).toFixed(1)}mm) {(sewingProgress.positionY / 10).toFixed(1)}mm)
</span> </span>
</div> </div>
<div className="detail-row"> <div className="flex justify-between py-2">
<span className="label">Progress:</span> <span className="font-medium text-gray-600">Progress:</span>
<span className="value">{progressPercent.toFixed(1)}%</span> <span className="font-semibold">{progressPercent.toFixed(1)}%</span>
</div> </div>
</div> </div>
)} )}
{/* State Visual Indicator */} {/* State Visual Indicator */}
{patternInfo && ( {patternInfo && (
<div className={`state-indicator state-indicator-${stateVisual.color}`}> <div className={`flex items-center gap-4 p-4 rounded-lg my-4 border-l-4 ${stateIndicatorColors[stateVisual.color as keyof typeof stateIndicatorColors] || stateIndicatorColors.info}`}>
<span className="state-icon">{stateVisual.icon}</span> <span className="text-3xl leading-none">{stateVisual.icon}</span>
<div className="state-info"> <div className="flex-1">
<div className="state-label">{stateVisual.label}</div> <div className="font-semibold text-base mb-1">{stateVisual.label}</div>
<div className="state-description">{stateVisual.description}</div> <div className="text-sm text-gray-600">{stateVisual.description}</div>
</div> </div>
</div> </div>
)} )}
<div className="progress-actions"> <div className="flex gap-3 mt-4 flex-wrap">
{/* Mask trace waiting for confirmation */} {/* Mask trace waiting for confirmation */}
{isMaskTraceWait && ( {isMaskTraceWait && (
<div className="status-message warning"> <div className="bg-yellow-100 text-yellow-800 px-4 py-3 rounded border border-yellow-200 font-medium w-full">
Press button on machine to start mask trace Press button on machine to start mask trace
</div> </div>
)} )}
{/* Mask trace in progress */} {/* Mask trace in progress */}
{isMaskTracing && ( {isMaskTracing && (
<div className="status-message info"> <div className="bg-cyan-100 text-cyan-800 px-4 py-3 rounded border border-cyan-200 font-medium w-full">
Mask trace in progress... Mask trace in progress...
</div> </div>
)} )}
@ -233,16 +245,16 @@ export function ProgressMonitor({
{/* Mask trace complete - ready to sew */} {/* Mask trace complete - ready to sew */}
{isMaskTraceComplete && ( {isMaskTraceComplete && (
<> <>
<div className="status-message success"> <div className="bg-green-100 text-green-800 px-4 py-3 rounded border border-green-200 font-medium w-full">
Mask trace complete! Mask trace complete!
</div> </div>
{canStartSewing(machineStatus) && ( {canStartSewing(machineStatus) && (
<button onClick={onStartSewing} className="btn-primary"> <button onClick={onStartSewing} className="px-6 py-3 bg-blue-600 text-white rounded font-semibold text-sm hover:bg-blue-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
Start Sewing Start Sewing
</button> </button>
)} )}
{canStartMaskTrace(machineStatus) && ( {canStartMaskTrace(machineStatus) && (
<button onClick={onStartMaskTrace} className="btn-secondary"> <button onClick={onStartMaskTrace} className="px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm hover:bg-gray-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
Trace Again Trace Again
</button> </button>
)} )}
@ -252,11 +264,11 @@ export function ProgressMonitor({
{/* Pattern uploaded, ready to trace */} {/* Pattern uploaded, ready to trace */}
{machineStatus === MachineStatus.IDLE && ( {machineStatus === MachineStatus.IDLE && (
<> <>
<div className="status-message info"> <div className="bg-cyan-100 text-cyan-800 px-4 py-3 rounded border border-cyan-200 font-medium w-full">
Pattern uploaded successfully Pattern uploaded successfully
</div> </div>
{canStartMaskTrace(machineStatus) && ( {canStartMaskTrace(machineStatus) && (
<button onClick={onStartMaskTrace} className="btn-secondary"> <button onClick={onStartMaskTrace} className="px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm hover:bg-gray-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
Start Mask Trace Start Mask Trace
</button> </button>
)} )}
@ -267,12 +279,12 @@ export function ProgressMonitor({
{machineStatus === MachineStatus.SEWING_WAIT && ( {machineStatus === MachineStatus.SEWING_WAIT && (
<> <>
{canStartMaskTrace(machineStatus) && ( {canStartMaskTrace(machineStatus) && (
<button onClick={onStartMaskTrace} className="btn-secondary"> <button onClick={onStartMaskTrace} className="px-6 py-3 bg-gray-600 text-white rounded font-semibold text-sm hover:bg-gray-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
Start Mask Trace Start Mask Trace
</button> </button>
)} )}
{canStartSewing(machineStatus) && ( {canStartSewing(machineStatus) && (
<button onClick={onStartSewing} className="btn-primary"> <button onClick={onStartSewing} className="px-6 py-3 bg-blue-600 text-white rounded font-semibold text-sm hover:bg-blue-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
Start Sewing Start Sewing
</button> </button>
)} )}
@ -281,42 +293,42 @@ export function ProgressMonitor({
{/* Resume sewing for interrupted states */} {/* Resume sewing for interrupted states */}
{canResumeSewing(machineStatus) && ( {canResumeSewing(machineStatus) && (
<button onClick={onResumeSewing} className="btn-primary"> <button onClick={onResumeSewing} className="px-6 py-3 bg-blue-600 text-white rounded font-semibold text-sm hover:bg-blue-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
Resume Sewing Resume Sewing
</button> </button>
)} )}
{/* Color change needed */} {/* Color change needed */}
{isColorChange && ( {isColorChange && (
<div className="status-message warning"> <div className="bg-yellow-100 text-yellow-800 px-4 py-3 rounded border border-yellow-200 font-medium w-full">
Waiting for color change - change thread and press button on machine Waiting for color change - change thread and press button on machine
</div> </div>
)} )}
{/* Sewing in progress */} {/* Sewing in progress */}
{isSewing && ( {isSewing && (
<div className="status-message info"> <div className="bg-cyan-100 text-cyan-800 px-4 py-3 rounded border border-cyan-200 font-medium w-full">
Sewing in progress... Sewing in progress...
</div> </div>
)} )}
{/* Sewing complete */} {/* Sewing complete */}
{isComplete && ( {isComplete && (
<div className="status-message success"> <div className="bg-green-100 text-green-800 px-4 py-3 rounded border border-green-200 font-medium w-full">
Sewing complete! Sewing complete!
</div> </div>
)} )}
{/* Delete pattern button - ONLY show when safe */} {/* Delete pattern button - ONLY show when safe */}
{patternInfo && canDeletePattern(machineStatus) && ( {patternInfo && canDeletePattern(machineStatus) && (
<button onClick={onDeletePattern} className="btn-danger"> <button onClick={onDeletePattern} className="px-6 py-3 bg-red-600 text-white rounded font-semibold text-sm hover:bg-red-700 transition-all hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale-[0.3]">
Delete Pattern Delete Pattern
</button> </button>
)} )}
{/* Show warning when delete is unavailable */} {/* Show warning when delete is unavailable */}
{patternInfo && !canDeletePattern(machineStatus) && ( {patternInfo && !canDeletePattern(machineStatus) && (
<div className="status-message info"> <div className="bg-cyan-100 text-cyan-800 px-4 py-3 rounded border border-cyan-200 font-medium w-full">
Pattern cannot be deleted during active operations Pattern cannot be deleted during active operations
</div> </div>
)} )}

View file

@ -5,6 +5,8 @@ body {
sans-serif; sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
color: #212529;
} }
code { code {

View file

@ -1,5 +1,6 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { viteStaticCopy } from 'vite-plugin-static-copy' import { viteStaticCopy } from 'vite-plugin-static-copy'
import { dirname, join } from 'path' import { dirname, join } from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@ -24,7 +25,7 @@ function viteStaticCopyPyodide() {
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), viteStaticCopyPyodide()], plugins: [react(), tailwindcss(), viteStaticCopyPyodide()],
optimizeDeps: { optimizeDeps: {
exclude: ['pyodide'], exclude: ['pyodide'],
}, },