Compare commits

..

No commits in common. "60762d1526a8547d0341a7150378e0ebb61750cb" and "7d424350569879f6998e130d790da44c3589b3e8" have entirely different histories.

11 changed files with 3155 additions and 1637 deletions

View file

@ -1,22 +0,0 @@
name: Autolabel
on:
pull_request:
types: [opened, reopened, synchronize]
permissions:
contents: write
pull-requests: write
jobs:
autolabel:
name: Autolabel PR
runs-on: ubuntu-latest
steps:
- name: Label PR
id: drafter
uses: release-drafter/release-drafter@v6
with:
disable-releaser: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -32,4 +32,4 @@ jobs:
run: npm run test:run run: npm run test:run
- name: Build application - name: Build application
run: npm run web:build run: npm run build

View file

@ -4,6 +4,9 @@ on:
push: push:
branches: branches:
- main - main
# pull_request event is required only for autolabeler
pull_request:
types: [opened, reopened, synchronize]
permissions: permissions:
contents: write contents: write
@ -21,8 +24,6 @@ jobs:
- name: Draft release - name: Draft release
id: drafter id: drafter
uses: release-drafter/release-drafter@v6 uses: release-drafter/release-drafter@v6
with:
disable-autolabeler: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -50,7 +51,7 @@ jobs:
run: npm ci run: npm ci
- name: Build web app - name: Build web app
run: npm run web:build -- --base=/respira/ run: npm run build -- --base=/respira/
- name: Create web artifact zip - name: Create web artifact zip
run: | run: |
@ -91,7 +92,7 @@ jobs:
run: npm ci run: npm ci
- name: Package and make - name: Package and make
run: npm run electron:make run: npm run make
- name: Upload Windows artifacts - name: Upload Windows artifacts
if: matrix.os == 'windows-latest' if: matrix.os == 'windows-latest'

4390
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,18 +8,18 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0",
"dev:electron": "vite --mode electron",
"build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"web:dev": "vite --host 0.0.0.0", "preview": "vite preview",
"web:preview": "vite preview",
"web:build": "tsc -b && vite build",
"test": "vitest", "test": "vitest",
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
"test:run": "vitest run", "test:run": "vitest run",
"electron:dev": "vite --mode electron", "start": "electron-forge start",
"electron:start": "electron-forge start", "package": "electron-forge package",
"electron:package": "electron-forge package", "make": "electron-forge make",
"electron:make": "electron-forge make", "icons": "electron-icon-builder --input=./public/icons/icon.png --output=./public -f"
"electron:icons": "electron-icon-builder --input=./public/icons/icon.png --output=./public -f"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
@ -28,7 +28,7 @@
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"electron-store": "^10.0.0", "electron-store": "^10.0.0",
"konva": "^10.0.12", "konva": "^10.0.12",
"pyodide": "^0.29.0", "pyodide": "^0.27.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-konva": "^19.2.1", "react-konva": "^19.2.1",

View file

@ -12,7 +12,6 @@ import { BluetoothDevicePicker } from './components/BluetoothDevicePicker';
import { getErrorDetails } from './utils/errorCodeHelpers'; import { getErrorDetails } from './utils/errorCodeHelpers';
import { getStateVisualInfo } from './utils/machineStateHelpers'; import { getStateVisualInfo } from './utils/machineStateHelpers';
import { CheckCircleIcon, BoltIcon, PauseCircleIcon, ExclamationTriangleIcon, ArrowPathIcon, XMarkIcon, InformationCircleIcon } from '@heroicons/react/24/solid'; import { CheckCircleIcon, BoltIcon, PauseCircleIcon, ExclamationTriangleIcon, ArrowPathIcon, XMarkIcon, InformationCircleIcon } from '@heroicons/react/24/solid';
import { isBluetoothSupported } from './utils/bluetoothSupport';
import './App.css'; import './App.css';
function App() { function App() {
@ -334,10 +333,8 @@ function App() {
<div className="flex-1 grid grid-cols-1 lg:grid-cols-[480px_1fr] gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden"> <div className="flex-1 grid grid-cols-1 lg:grid-cols-[480px_1fr] gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
{/* Left Column - Controls */} {/* Left Column - Controls */}
<div className="flex flex-col gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden"> <div className="flex flex-col gap-4 md:gap-5 lg:gap-6 lg:overflow-hidden">
{/* Connect Button or Browser Hint - Show when disconnected */} {/* Connect Button - Show when disconnected */}
{!isConnected && ( {!isConnected && (
<>
{isBluetoothSupported() ? (
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600"> <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5"> <div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
@ -357,37 +354,6 @@ function App() {
Connect to Machine Connect to Machine
</button> </button>
</div> </div>
) : (
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg shadow-md border-l-4 border-amber-500 dark:border-amber-600">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-6 h-6 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-amber-900 dark:text-amber-100 mb-2">Browser Not Supported</h3>
<p className="text-sm text-amber-800 dark:text-amber-200 mb-3">
Your browser doesn't support Web Bluetooth, which is required to connect to your embroidery machine.
</p>
<div className="space-y-2">
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">Please try one of these options:</p>
<ul className="text-sm text-amber-800 dark:text-amber-200 space-y-1.5 ml-4 list-disc">
<li>Use a supported browser (Chrome, Edge, or Opera)</li>
<li>
Download the Desktop app from{' '}
<a
href="https://github.com/jhbruhn/respira/releases/latest"
target="_blank"
rel="noopener noreferrer"
className="font-semibold underline hover:text-amber-900 dark:hover:text-amber-50 transition-colors"
>
GitHub Releases
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
)}
</>
)} )}
{/* Pattern File - Show during upload stage (before pattern is uploaded) */} {/* Pattern File - Show during upload stage (before pattern is uploaded) */}

View file

@ -6,7 +6,6 @@ import { useUIStore } from '../stores/useUIStore';
import { convertPesToPen, type PesPatternData } from '../formats/import/pesImporter'; import { convertPesToPen, type PesPatternData } from '../formats/import/pesImporter';
import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers'; import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers';
import { PatternInfoSkeleton } from './SkeletonLoader'; import { PatternInfoSkeleton } from './SkeletonLoader';
import { PatternInfo } from './PatternInfo';
import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon, FolderOpenIcon } from '@heroicons/react/24/solid'; import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon, FolderOpenIcon } from '@heroicons/react/24/solid';
import { createFileService } from '../platform'; import { createFileService } from '../platform';
import type { IFileService } from '../platform/interfaces/IFileService'; import type { IFileService } from '../platform/interfaces/IFileService';
@ -201,7 +200,66 @@ export function FileUpload() {
{!isLoading && pesData && ( {!isLoading && pesData && (
<div className="mb-3"> <div className="mb-3">
<PatternInfo pesData={pesData} showThreadBlocks /> <div className="grid grid-cols-3 gap-2 text-xs mb-2">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</span>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Stitches</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{pesData.stitchCount.toLocaleString()}
</span>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Colors / Blocks</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{pesData.uniqueColors.length} / {pesData.threads.length}
</span>
</div>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
<div className="flex gap-1">
{pesData.uniqueColors.slice(0, 8).map((color, idx) => {
// Primary metadata: brand and catalog number
const primaryMetadata = [
color.brand,
color.catalogNumber ? `#${color.catalogNumber}` : null
].filter(Boolean).join(" ");
// Secondary metadata: chart and description
const secondaryMetadata = [
color.chart,
color.description
].filter(Boolean).join(" ");
const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
const tooltipText = metadata
? `Color ${idx + 1}: ${color.hex} - ${metadata}`
: `Color ${idx + 1}: ${color.hex}`;
return (
<div
key={idx}
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: color.hex }}
title={tooltipText}
/>
);
})}
{pesData.uniqueColors.length > 8 && (
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300 leading-none">
+{pesData.uniqueColors.length - 8}
</div>
)}
</div>
</div>
</div> </div>
)} )}

View file

@ -1,92 +0,0 @@
import type { PesPatternData } from '../formats/import/pesImporter';
interface PatternInfoProps {
pesData: PesPatternData;
showThreadBlocks?: boolean;
}
export function PatternInfo({ pesData, showThreadBlocks = false }: PatternInfoProps) {
return (
<>
<div className="grid grid-cols-3 gap-2 text-xs mb-2">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</span>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Stitches</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{pesData.penStitches?.stitches.length.toLocaleString() || pesData.stitchCount.toLocaleString()}
{pesData.penStitches && pesData.penStitches.stitches.length !== pesData.stitchCount && (
<span
className="text-gray-500 dark:text-gray-500 font-normal ml-1"
title="Input stitch count from PES file (lock stitches were added for machine compatibility)"
>
({pesData.stitchCount.toLocaleString()})
</span>
)}
</span>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
{showThreadBlocks ? 'Colors / Blocks' : 'Colors'}
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{showThreadBlocks
? `${pesData.uniqueColors.length} / ${pesData.threads.length}`
: pesData.uniqueColors.length
}
</span>
</div>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
<div className="flex gap-1">
{pesData.uniqueColors.slice(0, 8).map((color, idx) => {
// Primary metadata: brand and catalog number
const primaryMetadata = [
color.brand,
color.catalogNumber ? `#${color.catalogNumber}` : null
].filter(Boolean).join(" ");
// Secondary metadata: chart and description
const secondaryMetadata = [
color.chart,
color.description
].filter(Boolean).join(" ");
const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
// Show which thread blocks use this color in PatternSummaryCard
const threadNumbers = color.threadIndices.map(i => i + 1).join(", ");
const tooltipText = showThreadBlocks
? (metadata
? `Color ${idx + 1}: ${color.hex} - ${metadata}`
: `Color ${idx + 1}: ${color.hex}`)
: (metadata
? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}`
: `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`);
return (
<div
key={idx}
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: color.hex }}
title={tooltipText}
/>
);
})}
{pesData.uniqueColors.length > 8 && (
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300 leading-none">
+{pesData.uniqueColors.length - 8}
</div>
)}
</div>
</div>
</>
);
}

View file

@ -2,7 +2,6 @@ import { useShallow } from 'zustand/react/shallow';
import { useMachineStore } from '../stores/useMachineStore'; import { useMachineStore } from '../stores/useMachineStore';
import { usePatternStore } from '../stores/usePatternStore'; import { usePatternStore } from '../stores/usePatternStore';
import { canDeletePattern } from '../utils/machineStateHelpers'; import { canDeletePattern } from '../utils/machineStateHelpers';
import { PatternInfo } from './PatternInfo';
import { DocumentTextIcon, TrashIcon } from '@heroicons/react/24/solid'; import { DocumentTextIcon, TrashIcon } from '@heroicons/react/24/solid';
export function PatternSummaryCard() { export function PatternSummaryCard() {
@ -45,7 +44,68 @@ export function PatternSummaryCard() {
</div> </div>
</div> </div>
<PatternInfo pesData={pesData} /> <div className="grid grid-cols-3 gap-2 text-xs mb-3">
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</span>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Stitches</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{pesData.stitchCount.toLocaleString()}
</span>
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Colors</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{pesData.uniqueColors.length}
</span>
</div>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
<div className="flex gap-1">
{pesData.uniqueColors.slice(0, 8).map((color, idx) => {
// Primary metadata: brand and catalog number
const primaryMetadata = [
color.brand,
color.catalogNumber ? `#${color.catalogNumber}` : null
].filter(Boolean).join(" ");
// Secondary metadata: chart and description
const secondaryMetadata = [
color.chart,
color.description
].filter(Boolean).join(" ");
const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
// Show which thread blocks use this color
const threadNumbers = color.threadIndices.map(i => i + 1).join(", ");
const tooltipText = metadata
? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}`
: `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`;
return (
<div
key={idx}
className="w-3 h-3 rounded-full border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: color.hex }}
title={tooltipText}
/>
);
})}
{pesData.uniqueColors.length > 8 && (
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600 border border-gray-400 dark:border-gray-500 flex items-center justify-center text-xs font-bold text-gray-600 dark:text-gray-300 leading-none">
+{pesData.uniqueColors.length - 8}
</div>
)}
</div>
</div>
{canDelete && ( {canDelete && (
<button <button

View file

@ -60,9 +60,9 @@ export function ProgressMonitor() {
? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100 ? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
: 0; : 0;
// Calculate color block information from decoded penStitches // Calculate color block information from pesData
const colorBlocks = useMemo(() => { const colorBlocks = useMemo(() => {
if (!pesData || !pesData.penStitches) return []; if (!pesData) return [];
const blocks: Array<{ const blocks: Array<{
colorIndex: number; colorIndex: number;
@ -76,20 +76,34 @@ export function ProgressMonitor() {
threadChart: string | null; threadChart: string | null;
}> = []; }> = [];
// Use the pre-computed color blocks from decoded PEN data let currentColorIndex = pesData.stitches[0]?.[3] ?? 0;
for (const penBlock of pesData.penStitches.colorBlocks) { let blockStartStitch = 0;
const thread = pesData.threads[penBlock.colorIndex];
for (let i = 0; i < pesData.stitches.length; i++) {
const stitchColorIndex = pesData.stitches[i][3];
// When color changes, save the previous block
if (
stitchColorIndex !== currentColorIndex ||
i === pesData.stitches.length - 1
) {
const endStitch = i === pesData.stitches.length - 1 ? i + 1 : i;
const thread = pesData.threads[currentColorIndex];
blocks.push({ blocks.push({
colorIndex: penBlock.colorIndex, colorIndex: currentColorIndex,
threadHex: thread?.hex || "#000000", threadHex: thread?.hex || "#000000",
threadCatalogNumber: thread?.catalogNumber ?? null, threadCatalogNumber: thread?.catalogNumber ?? null,
threadBrand: thread?.brand ?? null, threadBrand: thread?.brand ?? null,
threadDescription: thread?.description ?? null, threadDescription: thread?.description ?? null,
threadChart: thread?.chart ?? null, threadChart: thread?.chart ?? null,
startStitch: penBlock.startStitchIndex, startStitch: blockStartStitch,
endStitch: penBlock.endStitchIndex, endStitch: endStitch,
stitchCount: penBlock.endStitchIndex - penBlock.startStitchIndex, stitchCount: endStitch - blockStartStitch,
}); });
currentColorIndex = stitchColorIndex;
blockStartStitch = i;
}
} }
return blocks; return blocks;

View file

@ -1,17 +0,0 @@
/**
* Check if the current browser/environment supports Web Bluetooth API
* @returns true if Web Bluetooth is supported (or running in Electron), false otherwise
*/
export function isBluetoothSupported(): boolean {
// Always supported in Electron app
if (typeof window !== "undefined" && "electronAPI" in window) {
return true;
}
// Check for Web Bluetooth API support in browser
if (typeof navigator !== "undefined" && "bluetooth" in navigator) {
return true;
}
return false;
}