Compare commits

...

13 commits

Author SHA1 Message Date
dependabot[bot]
ba9090943e
build(deps-dev): bump @hono/node-server from 1.19.7 to 1.19.10
Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.7 to 1.19.10.
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](https://github.com/honojs/node-server/compare/v1.19.7...v1.19.10)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 1.19.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 11:46:42 +00:00
Jan-Henrik Bruhn
18f15c071b
Merge pull request #75 from jhbruhn/dependabot/npm_and_yarn/ajv-6.14.0
build(deps-dev): bump ajv from 6.12.6 to 6.14.0
2026-03-26 12:45:48 +01:00
Jan-Henrik Bruhn
3daa55d79d
Merge pull request #71 from jhbruhn/dependabot/npm_and_yarn/webpack-5.105.0
build(deps-dev): bump webpack from 5.104.0 to 5.105.0
2026-03-26 12:45:34 +01:00
Jan-Henrik Bruhn
a0029cfb17
Merge pull request #70 from jhbruhn/dependabot/npm_and_yarn/modelcontextprotocol/sdk-1.26.0
build(deps-dev): bump @modelcontextprotocol/sdk from 1.25.1 to 1.26.0
2026-03-26 12:45:21 +01:00
Jan-Henrik Bruhn
4cf2b09701
Merge pull request #85 from jhbruhn/preview-bg
feature: Add light/dark background toggle to pattern preview
2026-03-26 12:45:02 +01:00
7817835f16 feature: Add light/dark background toggle to pattern preview
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:42:57 +01:00
Jan-Henrik Bruhn
3e30909311
Merge pull request #84 from jhbruhn/step-control
feature: step control
2026-03-26 12:32:41 +01:00
4fd8ad284f fix: Improve step control UX and fix machine error display
- Consolidate progress stats into 3 cards (stitches, time, speed)
- Keep rollback info visible after machine clears error while paused
- Remove Resume/Start Sewing buttons in STOP state (error must be
  resolved on machine first)
- Use adjustedStitchIndex for progress display to prevent desync
- Make step control layout stable (always render all buttons)
- Reduce polling interval from 500ms to 1000ms during sewing
- Fix machine errors (e.g. HoopError) not showing in error badge
  when there was no accompanying string error message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:30:06 +01:00
7250e0e586 feature: Add stitch step control for error recovery and manual positioning
Implements automatic stitch rollback on thread errors (upper thread: -6,
lower thread: -2, sewing start: -21) and manual step controls to adjust
stitch position when machine is paused/stopped/interrupted. Adds UI with
step buttons (-100/-10/-1/+1/+10/+100), thread start jump, and current
stitch reset. Uses existing NEEDLE_MODE_INSTRUCTIONS (0x0709) BLE command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:04:38 +01:00
2f45f26942 chore: allow beads 2026-03-26 09:44:08 +01:00
dependabot[bot]
404132d2b5
build(deps-dev): bump ajv from 6.12.6 to 6.14.0
Bumps [ajv](https://github.com/ajv-validator/ajv) from 6.12.6 to 6.14.0.
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.6...v6.14.0)

---
updated-dependencies:
- dependency-name: ajv
  dependency-version: 6.14.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-22 15:17:48 +00:00
dependabot[bot]
2fe1ae5d2e
build(deps-dev): bump webpack from 5.104.0 to 5.105.0
Bumps [webpack](https://github.com/webpack/webpack) from 5.104.0 to 5.105.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Changelog](https://github.com/webpack/webpack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack/compare/v5.104.0...v5.105.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-version: 5.105.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-07 17:39:09 +00:00
dependabot[bot]
48dd09ec0e
build(deps-dev): bump @modelcontextprotocol/sdk from 1.25.1 to 1.26.0
Bumps [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) from 1.25.1 to 1.26.0.
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/1.25.1...v1.26.0)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/sdk"
  dependency-version: 1.26.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-04 20:39:08 +00:00
16 changed files with 692 additions and 195 deletions

View file

@ -11,7 +11,12 @@
"Bash(gh issue create:*)",
"Bash(gh label create:*)",
"Bash(gh issue view:*)",
"Bash(gh pr view:*)"
"Bash(gh pr view:*)",
"Bash(bd list:*)",
"Bash(bd status:*)",
"Bash(bd create:*)",
"Bash(bd dep add:*)",
"Bash(bd ready:*)"
],
"deny": [],
"ask": []

174
package-lock.json generated
View file

@ -17,6 +17,7 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17",
"@types/web-bluetooth": "^0.0.21",
@ -2528,9 +2529,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2674,9 +2675,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.7",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
"integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"dev": true,
"license": "MIT",
"engines": {
@ -3611,13 +3612,13 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.25.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.7",
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@ -3625,14 +3626,15 @@
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"jose": "^6.1.1",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"hono": "^4.11.4",
"jose": "^6.1.3",
"json-schema-typed": "^8.0.2",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.0"
"zod-to-json-schema": "^3.25.1"
},
"engines": {
"node": ">=18"
@ -4433,6 +4435,35 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
@ -4570,6 +4601,21 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
@ -6446,9 +6492,9 @@
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@ -6941,9 +6987,9 @@
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6953,7 +6999,7 @@
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
@ -6966,9 +7012,9 @@
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
"integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6983,9 +7029,9 @@
}
},
"node_modules/body-parser/node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@ -9290,13 +9336,13 @@
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.4",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
"integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
"tapable": "^2.3.0"
},
"engines": {
"node": ">=10.13.0"
@ -9636,9 +9682,9 @@
}
},
"node_modules/eslint/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -9974,11 +10020,14 @@
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
@ -9989,6 +10038,16 @@
"express": ">= 4.11"
}
},
"node_modules/express-rate-limit/node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/express/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@ -10017,9 +10076,9 @@
}
},
"node_modules/express/node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@ -11051,9 +11110,9 @@
}
},
"node_modules/har-validator/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -11168,7 +11227,6 @@
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@ -15010,9 +15068,9 @@
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
"integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -18539,9 +18597,9 @@
}
},
"node_modules/watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
"integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
"integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -18580,9 +18638,9 @@
"license": "BSD-2-Clause"
},
"node_modules/webpack": {
"version": "5.104.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.0.tgz",
"integrity": "sha512-5DeICTX8BVgNp6afSPYXAFjskIgWGlygQH58bcozPOXgo2r/6xx39Y1+cULZ3gTxUYQP88jmwLj2anu4Xaq84g==",
"version": "5.105.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz",
"integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -18596,7 +18654,7 @@
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.28.1",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.4",
"enhanced-resolve": "^5.19.0",
"es-module-lexer": "^2.0.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
@ -18609,7 +18667,7 @@
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.16",
"watchpack": "^2.4.4",
"watchpack": "^2.5.1",
"webpack-sources": "^3.3.3"
},
"bin": {
@ -19058,9 +19116,9 @@
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"dev": true,
"license": "ISC",
"peerDependencies": {

View file

@ -30,6 +30,7 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17",
"@types/web-bluetooth": "^0.0.21",

View file

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

View file

@ -10,67 +10,70 @@ interface GridProps {
gridSize: number;
bounds: { minX: number; maxX: number; minY: number; maxY: number };
machineInfo: MachineInfo | null;
colorOverride?: string;
}
export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => {
const lines = useMemo(() => {
const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX;
const gridMaxX = machineInfo ? machineInfo.maxWidth / 2 : bounds.maxX;
const gridMinY = machineInfo ? -machineInfo.maxHeight / 2 : bounds.minY;
const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY;
export const Grid = memo(
({ gridSize, bounds, machineInfo, colorOverride }: GridProps) => {
const lines = useMemo(() => {
const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX;
const gridMaxX = machineInfo ? machineInfo.maxWidth / 2 : bounds.maxX;
const gridMinY = machineInfo ? -machineInfo.maxHeight / 2 : bounds.minY;
const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY;
const verticalLines: number[][] = [];
const horizontalLines: number[][] = [];
const verticalLines: number[][] = [];
const horizontalLines: number[][] = [];
// Vertical lines
for (
let x = Math.floor(gridMinX / gridSize) * gridSize;
x <= gridMaxX;
x += gridSize
) {
verticalLines.push([x, gridMinY, x, gridMaxY]);
}
// Vertical lines
for (
let x = Math.floor(gridMinX / gridSize) * gridSize;
x <= gridMaxX;
x += gridSize
) {
verticalLines.push([x, gridMinY, x, gridMaxY]);
}
// Horizontal lines
for (
let y = Math.floor(gridMinY / gridSize) * gridSize;
y <= gridMaxY;
y += gridSize
) {
horizontalLines.push([gridMinX, y, gridMaxX, y]);
}
// Horizontal lines
for (
let y = Math.floor(gridMinY / gridSize) * gridSize;
y <= gridMaxY;
y += gridSize
) {
horizontalLines.push([gridMinX, y, gridMaxX, y]);
}
return { verticalLines, horizontalLines };
}, [gridSize, bounds, machineInfo]);
return { verticalLines, horizontalLines };
}, [gridSize, bounds, machineInfo]);
const gridColor = canvasColors.grid();
const gridColor = colorOverride ?? canvasColors.grid();
return (
<Group name="grid" listening={false}>
{lines.verticalLines.map((points, i) => (
<Line
key={`v-${i}`}
points={points}
stroke={gridColor}
strokeWidth={1}
/>
))}
{lines.horizontalLines.map((points, i) => (
<Line
key={`h-${i}`}
points={points}
stroke={gridColor}
strokeWidth={1}
/>
))}
</Group>
);
});
return (
<Group name="grid" listening={false}>
{lines.verticalLines.map((points, i) => (
<Line
key={`v-${i}`}
points={points}
stroke={gridColor}
strokeWidth={1}
/>
))}
{lines.horizontalLines.map((points, i) => (
<Line
key={`h-${i}`}
points={points}
stroke={gridColor}
strokeWidth={1}
/>
))}
</Group>
);
},
);
Grid.displayName = "Grid";
export const Origin = memo(() => {
const originColor = canvasColors.origin();
export const Origin = memo(({ colorOverride }: { colorOverride?: string }) => {
const originColor = colorOverride ?? canvasColors.origin();
return (
<Group name="origin" listening={false}>

View file

@ -1,4 +1,4 @@
import { useRef, useMemo } from "react";
import { useRef, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import {
useMachineStore,
@ -8,7 +8,7 @@ import { useMachineUploadStore } from "../../stores/useMachineUploadStore";
import { usePatternStore } from "../../stores/usePatternStore";
import { Stage, Layer } from "react-konva";
import Konva from "konva";
import { PhotoIcon } from "@heroicons/react/24/solid";
import { PhotoIcon, SunIcon, MoonIcon } from "@heroicons/react/24/solid";
import { Grid, Origin, Hoop } from "./KonvaComponents";
import {
Card,
@ -21,6 +21,7 @@ import { ThreadLegend } from "./ThreadLegend";
import { PatternPositionIndicator } from "./PatternPositionIndicator";
import { ZoomControls } from "./ZoomControls";
import { PatternLayer } from "./PatternLayer";
import { Switch } from "@/components/ui/switch";
import { useCanvasViewport, usePatternTransform } from "@/hooks";
export function PatternCanvas() {
@ -103,6 +104,16 @@ export function PatternCanvas() {
isUploading,
});
const [previewDark, setPreviewDark] = useState(
() => window.matchMedia("(prefers-color-scheme: dark)").matches,
);
const canvasBg = previewDark
? "bg-gray-900 border-gray-600"
: "bg-gray-200 border-gray-300";
const canvasGridColor = previewDark ? "#404040" : "#e0e0e0";
const canvasOriginColor = previewDark ? "#999999" : "#888888";
const hasPattern = pesData || uploadedPesData;
const borderColor = hasPattern
? "border-tertiary-600 dark:border-tertiary-500"
@ -138,23 +149,34 @@ export function PatternCanvas() {
<CardHeader className="p-4 pb-3">
<div className="flex items-start gap-3">
<PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<CardTitle className="text-sm">Pattern Preview</CardTitle>
{hasPattern ? (
<CardDescription className="text-xs">
{patternDimensions}
</CardDescription>
) : (
<CardDescription className="text-xs">
No pattern loaded
</CardDescription>
)}
<div className="flex-1 min-w-0 flex items-center justify-between">
<div>
<CardTitle className="text-sm">Pattern Preview</CardTitle>
{hasPattern ? (
<CardDescription className="text-xs">
{patternDimensions}
</CardDescription>
) : (
<CardDescription className="text-xs">
No pattern loaded
</CardDescription>
)}
</div>
<div className="flex items-center gap-1.5">
<SunIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
<Switch
checked={previewDark}
onCheckedChange={setPreviewDark}
aria-label="Toggle preview background"
/>
<MoonIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
</div>
</div>
</div>
</CardHeader>
<CardContent className="px-4 pt-0 pb-4 flex-1 flex flex-col min-h-0">
<div
className="relative w-full flex-1 min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
className={`relative w-full flex-1 min-h-0 border rounded overflow-hidden ${canvasBg}`}
ref={containerRef}
>
{containerSize.width > 0 && (
@ -184,8 +206,9 @@ export function PatternCanvas() {
gridSize={100}
bounds={displayPattern.bounds}
machineInfo={machineInfo}
colorOverride={canvasGridColor}
/>
<Origin />
<Origin colorOverride={canvasOriginColor} />
{machineInfo && <Hoop machineInfo={machineInfo} />}
</>
)}

View file

@ -17,6 +17,7 @@ interface ProgressActionsProps {
machineStatus: MachineStatus;
isDeleting: boolean;
isMaskTraceComplete: boolean;
hasSewingProgress: boolean;
onResumeSewing: () => void;
onStartSewing: () => void;
onStartMaskTrace: () => void;
@ -26,6 +27,7 @@ export function ProgressActions({
machineStatus,
isDeleting,
isMaskTraceComplete,
hasSewingProgress,
onResumeSewing,
onStartSewing,
onStartMaskTrace,
@ -59,7 +61,7 @@ export function ProgressActions({
)}
{/* Start Mask Trace - secondary action */}
{canStartMaskTrace(machineStatus) && (
{canStartMaskTrace(machineStatus, hasSewingProgress) && (
<Button
onClick={onStartMaskTrace}
disabled={isDeleting}

View file

@ -23,10 +23,12 @@ import {
CardDescription,
CardContent,
} from "@/components/ui/card";
import { canShowStepControl } from "../../utils/machineStateHelpers";
import { ProgressStats } from "./ProgressStats";
import { ProgressSection } from "./ProgressSection";
import { ColorBlockList } from "./ColorBlockList";
import { ProgressActions } from "./ProgressActions";
import { StitchStepControl } from "./StitchStepControl";
export function ProgressMonitor() {
// Machine store
@ -35,18 +37,28 @@ export function ProgressMonitor() {
patternInfo,
sewingProgress,
isDeleting,
adjustedStitchIndex,
lastRolledBackError,
pausedStitchIndex,
startMaskTrace,
startSewing,
resumeSewing,
adjustStitchPosition,
setStitchPosition,
} = useMachineStore(
useShallow((state) => ({
machineStatus: state.machineStatus,
patternInfo: state.patternInfo,
sewingProgress: state.sewingProgress,
isDeleting: state.isDeleting,
adjustedStitchIndex: state.adjustedStitchIndex,
lastRolledBackError: state.lastRolledBackError,
pausedStitchIndex: state.pausedStitchIndex,
startMaskTrace: state.startMaskTrace,
startSewing: state.startSewing,
resumeSewing: state.resumeSewing,
adjustStitchPosition: state.adjustStitchPosition,
setStitchPosition: state.setStitchPosition,
})),
);
@ -66,19 +78,18 @@ export function ProgressMonitor() {
: patternInfo.totalStitches
: 0;
// Use adjustedStitchIndex (from step control) when available, otherwise machine-reported
const currentStitch =
adjustedStitchIndex ?? sewingProgress?.currentStitch ?? 0;
const progressPercent =
totalStitches > 0
? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
: 0;
totalStitches > 0 ? (currentStitch / totalStitches) * 100 : 0;
// Calculate color block information from decoded penStitches
const colorBlocks = useMemo(
() => calculateColorBlocks(displayPattern),
[displayPattern],
);
// Determine current color block based on current stitch
const currentStitch = sewingProgress?.currentStitch || 0;
const currentBlockIndex = findCurrentBlockIndex(colorBlocks, currentStitch);
// Calculate time based on color blocks (matches Brother app calculation)
@ -115,7 +126,9 @@ export function ProgressMonitor() {
{/* Pattern Info */}
{patternInfo && (
<ProgressStats
currentStitch={currentStitch}
totalStitches={totalStitches}
elapsedMinutes={elapsedMinutes}
totalMinutes={totalMinutes}
speed={patternInfo.speed}
/>
@ -123,13 +136,7 @@ export function ProgressMonitor() {
{/* Progress Bar */}
{sewingProgress && (
<ProgressSection
currentStitch={sewingProgress.currentStitch}
totalStitches={totalStitches}
elapsedMinutes={elapsedMinutes}
totalMinutes={totalMinutes}
progressPercent={progressPercent}
/>
<ProgressSection progressPercent={progressPercent} />
)}
{/* Color Blocks */}
@ -140,11 +147,26 @@ export function ProgressMonitor() {
currentBlockRef={currentBlockRef}
/>
{/* Step control for paused/stopped/error states */}
{canShowStepControl(machineStatus, currentStitch > 0) && (
<StitchStepControl
currentStitch={currentStitch}
adjustedStitchIndex={adjustedStitchIndex}
pausedStitchIndex={pausedStitchIndex}
totalStitches={totalStitches}
lastRolledBackError={lastRolledBackError}
colorBlocks={colorBlocks}
onAdjustPosition={adjustStitchPosition}
onSetPosition={setStitchPosition}
/>
)}
{/* Action buttons */}
<ProgressActions
machineStatus={machineStatus}
isDeleting={isDeleting}
isMaskTraceComplete={isMaskTraceComplete}
hasSewingProgress={currentStitch > 0}
onResumeSewing={resumeSewing}
onStartSewing={startSewing}
onStartMaskTrace={startMaskTrace}

View file

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

View file

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

View file

@ -0,0 +1,187 @@
/**
* StitchStepControl Component
*
* Compact stitch position control shown when machine is paused/stopped/interrupted.
* Allows stepping forward/backward by 1, 10, or 100 stitches,
* jumping to thread color boundaries, and resetting to current position.
*/
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
SwatchIcon,
ArrowUturnLeftIcon,
} from "@heroicons/react/24/solid";
import { Button } from "@/components/ui/button";
import {
getErrorStitchRollback,
getErrorMessage,
} from "../../utils/errorCodeHelpers";
import type { ColorBlock } from "./types";
import { findCurrentBlockIndex } from "../../utils/colorBlockHelpers";
interface StitchStepControlProps {
currentStitch: number;
adjustedStitchIndex: number | null;
pausedStitchIndex: number | null;
totalStitches: number;
lastRolledBackError: number | null;
colorBlocks: ColorBlock[];
onAdjustPosition: (offset: number) => void;
onSetPosition: (index: number) => void;
}
export function StitchStepControl({
currentStitch,
adjustedStitchIndex,
pausedStitchIndex,
totalStitches,
lastRolledBackError,
colorBlocks,
onAdjustPosition,
onSetPosition,
}: StitchStepControlProps) {
const displayStitch = adjustedStitchIndex ?? currentStitch;
const handleGoToThreadStart = () => {
const blockIndex = findCurrentBlockIndex(colorBlocks, displayStitch);
if (blockIndex >= 0) {
onSetPosition(colorBlocks[blockIndex].startStitch);
}
};
const handleGoToPausedStitch = () => {
if (pausedStitchIndex !== null) {
onSetPosition(pausedStitchIndex);
}
};
const rollbackAmount = lastRolledBackError
? getErrorStitchRollback(lastRolledBackError)
: null;
const rollbackErrorName = lastRolledBackError
? getErrorMessage(lastRolledBackError)
: null;
const showGoToPaused =
pausedStitchIndex !== null && displayStitch !== pausedStitchIndex;
return (
<div className="mb-3 bg-gray-200 dark:bg-gray-700/50 px-3 py-2 rounded-lg">
{/* Header: label + stitch count on one line */}
<div className="flex items-baseline justify-between mb-1.5">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
Stitch Position
</span>
<span>
<span className="text-sm font-bold text-gray-900 dark:text-gray-100 tabular-nums">
{displayStitch.toLocaleString()}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
/ {totalStitches.toLocaleString()}
</span>
</span>
</div>
{/* Rollback info */}
{rollbackAmount !== null && rollbackErrorName && (
<div className="text-xs text-amber-600 dark:text-amber-400 text-center mb-1.5">
Moved back {rollbackAmount} stitches (
{rollbackErrorName.toLowerCase()})
</div>
)}
{/* Step buttons + navigation in one row */}
<div className="flex items-center justify-center gap-1">
{colorBlocks.length > 0 && (
<Button
variant="outline"
size="icon-sm"
onClick={handleGoToThreadStart}
title="Go to the beginning of the selected thread color"
aria-label="Go to the beginning of the selected thread color"
>
<SwatchIcon className="w-4 h-4" />
</Button>
)}
<Button
variant="outline"
size="icon-sm"
onClick={() => onAdjustPosition(-100)}
disabled={displayStitch <= 0}
aria-label="Back 100 stitches"
title="-100"
>
<ChevronDoubleLeftIcon className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onAdjustPosition(-10)}
disabled={displayStitch <= 0}
aria-label="Back 10 stitches"
title="-10"
>
<ChevronLeftIcon className="w-4 h-4 -mr-1" />
<ChevronLeftIcon className="w-4 h-4 -ml-1" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onAdjustPosition(-1)}
disabled={displayStitch <= 0}
aria-label="Back 1 stitch"
title="-1"
>
<ChevronLeftIcon className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onAdjustPosition(1)}
disabled={displayStitch >= totalStitches}
aria-label="Forward 1 stitch"
title="+1"
>
<ChevronRightIcon className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onAdjustPosition(10)}
disabled={displayStitch >= totalStitches}
aria-label="Forward 10 stitches"
title="+10"
>
<ChevronRightIcon className="w-4 h-4 -mr-1" />
<ChevronRightIcon className="w-4 h-4 -ml-1" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => onAdjustPosition(100)}
disabled={displayStitch >= totalStitches}
aria-label="Forward 100 stitches"
title="+100"
>
<ChevronDoubleRightIcon className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={handleGoToPausedStitch}
disabled={!showGoToPaused}
title="Go to the current stitch"
aria-label="Go to the current stitch"
>
<ArrowUturnLeftIcon className="w-4 h-4" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View file

@ -626,6 +626,13 @@ export class BrotherPP1Service {
await this.sendCommand(Commands.MASK_TRACE, payload);
}
async setStitchIndex(stitchIndex: number): Promise<void> {
const payload = new Uint8Array(2);
payload[0] = stitchIndex & 0xff; // Low byte
payload[1] = (stitchIndex >> 8) & 0xff; // High byte
await this.sendCommand(Commands.NEEDLE_MODE_INSTRUCTIONS, payload);
}
async startSewing(): Promise<void> {
await this.sendCommand(Commands.START_SEWING);
}

View file

@ -9,7 +9,11 @@ import type {
SewingProgress,
} from "../types/machine";
import { MachineStatus, MachineStatusNames } from "../types/machine";
import { SewingMachineError } from "../utils/errorCodeHelpers";
import {
SewingMachineError,
getErrorStitchRollback,
} from "../utils/errorCodeHelpers";
import { getMachineStateCategory } from "../utils/machineStateHelpers";
import { uuidToString } from "../services/PatternCacheService";
import { createStorageService } from "../platform";
import type { IStorageService } from "../platform/interfaces/IStorageService";
@ -41,6 +45,11 @@ interface MachineState {
isCommunicating: boolean;
isDeleting: boolean;
// Step control state
adjustedStitchIndex: number | null;
lastRolledBackError: number | null;
pausedStitchIndex: number | null; // Position snapshot after pause + auto-rollback, before manual adjustments
// Polling control
pollIntervalId: NodeJS.Timeout | null;
serviceCountIntervalId: NodeJS.Timeout | null;
@ -56,6 +65,8 @@ interface MachineState {
startSewing: () => Promise<void>;
resumeSewing: () => Promise<void>;
deletePattern: () => Promise<void>;
setStitchPosition: (index: number) => Promise<void>;
adjustStitchPosition: (offset: number) => Promise<void>;
// Initialization
initialize: () => void;
@ -64,6 +75,7 @@ interface MachineState {
_setupSubscriptions: () => void;
_startPolling: () => void;
_stopPolling: () => void;
_handleErrorStitchRollback: () => Promise<void>;
}
export const useMachineStore = create<MachineState>((set, get) => ({
@ -81,6 +93,9 @@ export const useMachineStore = create<MachineState>((set, get) => ({
isPairingError: false,
isCommunicating: false,
isDeleting: false,
adjustedStitchIndex: null,
lastRolledBackError: null,
pausedStitchIndex: null,
pollIntervalId: null,
serviceCountIntervalId: null,
@ -104,6 +119,14 @@ export const useMachineStore = create<MachineState>((set, get) => ({
machineError: state.error,
});
// Fetch sewing progress so we know if sewing was in progress before reconnect
try {
const progress = await service.getSewingProgress();
set({ sewingProgress: progress });
} catch {
// Not critical - polling will pick it up
}
// Check for resume possibility using cache store
const { useMachineCacheStore } = await import("./useMachineCacheStore");
await useMachineCacheStore.getState().checkResume();
@ -237,7 +260,12 @@ export const useMachineStore = create<MachineState>((set, get) => ({
if (!isConnected) return;
try {
set({ error: null });
set({
error: null,
adjustedStitchIndex: null,
lastRolledBackError: null,
pausedStitchIndex: null,
});
await service.startSewing();
await refreshStatus();
} catch (err) {
@ -253,7 +281,12 @@ export const useMachineStore = create<MachineState>((set, get) => ({
if (!isConnected) return;
try {
set({ error: null });
set({
error: null,
adjustedStitchIndex: null,
lastRolledBackError: null,
pausedStitchIndex: null,
});
await service.resumeSewing();
await refreshStatus();
} catch (err) {
@ -304,6 +337,64 @@ export const useMachineStore = create<MachineState>((set, get) => ({
}
},
// Set stitch position to an absolute index
setStitchPosition: async (index: number) => {
const { isConnected, service, patternInfo } = get();
if (!isConnected) return;
const totalStitches = patternInfo?.totalStitches || 0;
const clamped = Math.max(0, Math.min(index, totalStitches));
try {
await service.setStitchIndex(clamped);
set({ adjustedStitchIndex: clamped });
// Refresh progress so UI reflects the new position
await get().refreshProgress();
} catch (err) {
set({
error:
err instanceof Error ? err.message : "Failed to set stitch position",
});
}
},
// Adjust stitch position by a relative offset
adjustStitchPosition: async (offset: number) => {
const { sewingProgress, adjustedStitchIndex } = get();
const currentIndex =
adjustedStitchIndex ?? sewingProgress?.currentStitch ?? 0;
await get().setStitchPosition(currentIndex + offset);
},
// Handle automatic stitch rollback for thread errors
_handleErrorStitchRollback: async () => {
const { machineError, sewingProgress, service, lastRolledBackError } =
get();
const rollback = getErrorStitchRollback(machineError);
if (rollback === null) return;
if (machineError === lastRolledBackError) return;
const currentStitch = sewingProgress?.currentStitch ?? 0;
const newIndex = Math.max(0, currentStitch - rollback);
console.log(
`[StepControl] Auto-rollback: stitch ${currentStitch} -> ${newIndex} (error 0x${machineError.toString(16)}, rollback ${rollback})`,
);
try {
await service.setStitchIndex(newIndex);
set({
adjustedStitchIndex: newIndex,
lastRolledBackError: machineError,
});
// Immediately refresh progress so subsequent polls see consistent state
await get().refreshProgress();
} catch (err) {
console.error("[StepControl] Failed to rollback stitch position:", err);
}
},
// Initialize the store (call once from App component)
initialize: () => {
get()._setupSubscriptions();
@ -359,7 +450,7 @@ export const useMachineStore = create<MachineState>((set, get) => ({
status === MachineStatus.MASK_TRACING ||
status === MachineStatus.SEWING_DATA_RECEIVE
) {
return 500;
return 1000;
} else if (
status === MachineStatus.COLOR_CHANGE_WAIT ||
status === MachineStatus.MASK_TRACE_LOCK_WAIT ||
@ -374,11 +465,48 @@ export const useMachineStore = create<MachineState>((set, get) => ({
const poll = async () => {
await refreshStatus();
const currentState = get();
const category = getMachineStateCategory(currentState.machineStatus);
// Refresh progress during sewing
if (get().machineStatus === MachineStatus.SEWING) {
if (currentState.machineStatus === MachineStatus.SEWING) {
await refreshProgress();
}
// Reset step control state when machine is actively sewing
if (category === "active") {
if (
currentState.adjustedStitchIndex !== null ||
currentState.lastRolledBackError !== null ||
currentState.pausedStitchIndex !== null
) {
set({
adjustedStitchIndex: null,
lastRolledBackError: null,
pausedStitchIndex: null,
});
}
}
// Note: we intentionally do NOT clear lastRolledBackError when the error clears
// while still paused, so the rollback info text remains visible to the user.
// Auto-rollback for thread errors when machine is interrupted or paused mid-sew
// Only runs once on entering paused state (when pausedStitchIndex is not yet set)
const isInterruptedOrPausedMidSew =
category === "interrupted" ||
(currentState.machineStatus === MachineStatus.SEWING_WAIT &&
(currentState.sewingProgress?.currentStitch ?? 0) > 0);
if (isInterruptedOrPausedMidSew && get().pausedStitchIndex === null) {
// Refresh progress so rollback has accurate current stitch
await get().refreshProgress();
await get()._handleErrorStitchRollback();
// Snapshot the paused position (after rollback, before manual adjustments)
const postRollbackStitch =
get().adjustedStitchIndex ?? get().sewingProgress?.currentStitch ?? 0;
set({ pausedStitchIndex: postRollbackStitch });
}
// follows the apps logic:
// Check if we have a cached pattern and pattern info needs refreshing
const { useMachineCacheStore } = await import("./useMachineCacheStore");
@ -434,6 +562,12 @@ export const usePatternInfo = () =>
useMachineStore((state) => state.patternInfo);
export const useSewingProgress = () =>
useMachineStore((state) => state.sewingProgress);
export const useAdjustedStitchIndex = () =>
useMachineStore((state) => state.adjustedStitchIndex);
export const useLastRolledBackError = () =>
useMachineStore((state) => state.lastRolledBackError);
export const usePausedStitchIndex = () =>
useMachineStore((state) => state.pausedStitchIndex);
// Derived state: pattern is uploaded if machine has pattern info
export const usePatternUploaded = () =>
useMachineStore((state) => state.patternInfo !== null);

View file

@ -412,6 +412,24 @@ export function getErrorDetails(
};
}
/**
* Get the number of stitches to roll back for a given error code.
* Returns null if the error does not trigger automatic rollback.
* Based on Artspira ChangeStitchForError logic.
*/
export function getErrorStitchRollback(errorCode: number): number | null {
switch (errorCode) {
case SewingMachineError.UpperThreadError:
return 6;
case SewingMachineError.LowerThreadError:
return 2;
case SewingMachineError.UpperThreadSewingStartError:
return 21;
default:
return null;
}
}
/**
* Export ErrorInfo type for use in other files
*/

View file

@ -97,34 +97,67 @@ export function canUploadPattern(status: MachineStatus): boolean {
export function canStartSewing(status: MachineStatus): boolean {
// Only in specific ready states
return (
status === MachineStatus.SEWING_WAIT ||
status === MachineStatus.MASK_TRACE_COMPLETE ||
status === MachineStatus.PAUSE ||
status === MachineStatus.STOP ||
status === MachineStatus.SEWING_INTERRUPTION
);
}
/**
* Determines if mask trace can be started in the current state.
* When hasSewingProgress is true, SEWING_WAIT means the machine is paused mid-sew,
* not waiting for initial start - mask trace should not be offered.
*/
export function canStartMaskTrace(status: MachineStatus): boolean {
// Can start mask trace when IDLE (after upload), SEWING_WAIT, or after previous trace
return (
export function canStartMaskTrace(
status: MachineStatus,
hasSewingProgress = false,
): boolean {
if (
status === MachineStatus.IDLE ||
status === MachineStatus.SEWING_WAIT ||
status === MachineStatus.MASK_TRACE_COMPLETE
);
) {
return true;
}
// Only allow mask trace in SEWING_WAIT if sewing hasn't started yet
if (status === MachineStatus.SEWING_WAIT && !hasSewingProgress) {
return true;
}
return false;
}
/**
* Determines if sewing can be resumed in the current state.
* Only for interrupted operations (PAUSE, STOP, SEWING_INTERRUPTION).
* Only for PAUSE and SEWING_INTERRUPTION - not STOP, which requires
* the user to resolve the error on the machine first.
*/
export function canResumeSewing(status: MachineStatus): boolean {
// Only in interrupted states
const category = getMachineStateCategory(status);
return category === MachineStateCategory.INTERRUPTED;
return (
status === MachineStatus.PAUSE ||
status === MachineStatus.SEWING_INTERRUPTION
);
}
/**
* Determines if the step control UI should be shown.
* Allows manual stitch position adjustment when machine is paused/stopped/interrupted,
* or in SEWING_WAIT if sewing has already started (currentStitch > 0).
*/
export function canShowStepControl(
status: MachineStatus,
hasSewingProgress: boolean,
): boolean {
if (
status === MachineStatus.PAUSE ||
status === MachineStatus.STOP ||
status === MachineStatus.SEWING_INTERRUPTION
) {
return true;
}
// SEWING_WAIT is also the paused state; show controls only if sewing already started
if (status === MachineStatus.SEWING_WAIT && hasSewingProgress) {
return true;
}
return false;
}
/**