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
- name: Build application
run: npm run web:build
run: npm run build

View file

@ -4,6 +4,9 @@ on:
push:
branches:
- main
# pull_request event is required only for autolabeler
pull_request:
types: [opened, reopened, synchronize]
permissions:
contents: write
@ -21,8 +24,6 @@ jobs:
- name: Draft release
id: drafter
uses: release-drafter/release-drafter@v6
with:
disable-autolabeler: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -50,7 +51,7 @@ jobs:
run: npm ci
- name: Build web app
run: npm run web:build -- --base=/respira/
run: npm run build -- --base=/respira/
- name: Create web artifact zip
run: |
@ -91,7 +92,7 @@ jobs:
run: npm ci
- name: Package and make
run: npm run electron:make
run: npm run make
- name: Upload Windows artifacts
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",
"main": ".vite/build/main.js",
"scripts": {
"dev": "vite --host 0.0.0.0",
"dev:electron": "vite --mode electron",
"build": "tsc -b && vite build",
"lint": "eslint .",
"web:dev": "vite --host 0.0.0.0",
"web:preview": "vite preview",
"web:build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"electron:dev": "vite --mode electron",
"electron:start": "electron-forge start",
"electron:package": "electron-forge package",
"electron:make": "electron-forge make",
"electron:icons": "electron-icon-builder --input=./public/icons/icon.png --output=./public -f"
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"icons": "electron-icon-builder --input=./public/icons/icon.png --output=./public -f"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
@ -28,7 +28,7 @@
"electron-squirrel-startup": "^1.0.1",
"electron-store": "^10.0.0",
"konva": "^10.0.12",
"pyodide": "^0.29.0",
"pyodide": "^0.27.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-konva": "^19.2.1",

View file

@ -12,7 +12,6 @@ import { BluetoothDevicePicker } from './components/BluetoothDevicePicker';
import { getErrorDetails } from './utils/errorCodeHelpers';
import { getStateVisualInfo } from './utils/machineStateHelpers';
import { CheckCircleIcon, BoltIcon, PauseCircleIcon, ExclamationTriangleIcon, ArrowPathIcon, XMarkIcon, InformationCircleIcon } from '@heroicons/react/24/solid';
import { isBluetoothSupported } from './utils/bluetoothSupport';
import './App.css';
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">
{/* Left Column - Controls */}
<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 && (
<>
{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="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">
@ -357,37 +354,6 @@ function App() {
Connect to Machine
</button>
</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) */}

View file

@ -6,7 +6,6 @@ import { useUIStore } from '../stores/useUIStore';
import { convertPesToPen, type PesPatternData } from '../formats/import/pesImporter';
import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers';
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';
@ -201,7 +200,66 @@ export function FileUpload() {
{!isLoading && pesData && (
<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>
)}

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 { usePatternStore } from '../stores/usePatternStore';
import { canDeletePattern } from '../utils/machineStateHelpers';
import { PatternInfo } from './PatternInfo';
import { DocumentTextIcon, TrashIcon } from '@heroicons/react/24/solid';
export function PatternSummaryCard() {
@ -45,7 +44,68 @@ export function PatternSummaryCard() {
</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 && (
<button

View file

@ -60,9 +60,9 @@ export function ProgressMonitor() {
? ((sewingProgress?.currentStitch || 0) / patternInfo.totalStitches) * 100
: 0;
// Calculate color block information from decoded penStitches
// Calculate color block information from pesData
const colorBlocks = useMemo(() => {
if (!pesData || !pesData.penStitches) return [];
if (!pesData) return [];
const blocks: Array<{
colorIndex: number;
@ -76,20 +76,34 @@ export function ProgressMonitor() {
threadChart: string | null;
}> = [];
// Use the pre-computed color blocks from decoded PEN data
for (const penBlock of pesData.penStitches.colorBlocks) {
const thread = pesData.threads[penBlock.colorIndex];
let currentColorIndex = pesData.stitches[0]?.[3] ?? 0;
let blockStartStitch = 0;
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({
colorIndex: penBlock.colorIndex,
colorIndex: currentColorIndex,
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,
startStitch: blockStartStitch,
endStitch: endStitch,
stitchCount: endStitch - blockStartStitch,
});
currentColorIndex = stitchColorIndex;
blockStartStitch = i;
}
}
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;
}