diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000..83b18b0 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,35 @@ +name: "Copilot Setup Steps" + +# Automatically run the setup steps when they are changed to allow for easy validation, and +# allow manual testing through the repository's "Actions" tab +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + + # Set the permissions to the lowest permissions possible needed for your steps. + # Copilot will be given its own token for its operations. + permissions: + # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete. + contents: read + + # You can define any steps you want, and they will run before the agent starts. + # If you do not check out your code, Copilot will do this for you. + steps: + - name: Checkout code + uses: actions/checkout@v5 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: "npm" + - name: Install dependencies + run: npm install + - name: Enable pre-commit hooks + run: npm run prepare diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f450893..dd9d6ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ name: Release DYA Studio on: - workflow_dispatch: + workflow_dispatch: jobs: build: @@ -27,4 +27,4 @@ jobs: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_RELEASE }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID_RELEASE }} environment: release - wranglerVersion: '4.58.0' + wranglerVersion: "4.58.0" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a2640b5..e91b3ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -86,7 +86,7 @@ jobs: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: versions upload --assets dist --name dya-studio --compatibility-date 2026-01-11 - wranglerVersion: '4.58.0' + wranglerVersion: "4.58.0" - name: Comment PR with deployment URL uses: actions/github-script@v7 @@ -123,4 +123,4 @@ jobs: issue_number: context.issue.number, body: comment }); - } \ No newline at end of file + } diff --git a/.gitignore b/.gitignore index ee4e5e1..12bb520 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ coverage *.njsproj *.sln *.sw? +_codeql_detected_source_root diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..1c0ebca --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +npx lint-staged +npm test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..be4ad67 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +dist +node_modules +coverage +*.min.js +*.min.css +pnpm-lock.yaml +package-lock.json +src/proto diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8daa855 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/docs/PRECOMMIT_SETUP.md b/docs/PRECOMMIT_SETUP.md new file mode 100644 index 0000000..d298d71 --- /dev/null +++ b/docs/PRECOMMIT_SETUP.md @@ -0,0 +1,56 @@ +# Pre-commit Hooks Setup + +This project uses [Husky](https://typicode.github.io/husky/) and [lint-staged](https://github.com/lint-staged/lint-staged) to ensure code quality before commits. + +## What runs on pre-commit? + +1. **Formatting**: Prettier formats staged files (`.ts`, `.tsx`, `.json`, `.md`, `.yml`, `.yaml`) +2. **Linting**: ESLint checks and auto-fixes staged TypeScript files +3. **Testing**: Full test suite runs to ensure nothing is broken + +## How it works + +When you run `git commit`, the pre-commit hook automatically: + +- Runs `lint-staged` to format and lint only the staged files +- Runs `npm test` to verify all tests pass +- If any step fails, the commit is aborted + +## Manual commands + +You can also run these checks manually: + +```bash +# Format all files +npm run format + +# Check formatting without changing files +npm run format:check + +# Run linting +npm run lint + +# Run tests +npm test +``` + +## First-time setup + +If you clone this repository, run: + +```bash +npm install +``` + +This will automatically set up Husky hooks via the `prepare` script. + +## GitHub Actions + +The project includes a Copilot workflow (`.github/workflows/copilot.yml`) that runs: + +- Format check +- Lint +- Tests +- Build + +This ensures all code quality checks pass in CI/CD. diff --git a/eslint.config.js b/eslint.config.js index 5e6b472..75d3c46 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,14 +1,14 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist"]), { - files: ['**/*.{ts,tsx}'], + files: ["**/*.{ts,tsx}"], extends: [ js.configs.recommended, tseslint.configs.recommended, @@ -20,4 +20,4 @@ export default defineConfig([ globals: globals.browser, }, }, -]) +]); diff --git a/package-lock.json b/package-lock.json index a7732d6..bdd71ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,10 +42,13 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "husky": "^9.1.7", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^30.2.0", + "lint-staged": "^16.2.7", "postcss": "^8.5.6", + "prettier": "^3.8.1", "run-script-os": "^1.1.6", "tailwindcss": "^4.1.18", "ts-jest": "^29.4.6", @@ -5477,6 +5480,85 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz", + "integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -5539,6 +5621,23 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6033,6 +6132,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -6544,6 +6656,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -6758,6 +6883,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -9372,6 +9513,134 @@ "dev": true, "license": "MIT" }, + "node_modules/lint-staged": { + "version": "16.2.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", + "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.2", + "listr2": "^9.0.5", + "micromatch": "^4.0.8", + "nano-spawn": "^2.0.0", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -9402,6 +9671,127 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", + "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -9539,6 +9929,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -9594,6 +9997,19 @@ "dev": true, "license": "MIT" }, + "node_modules/nano-spawn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", + "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -9893,6 +10309,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -10018,6 +10447,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -10382,6 +10827,59 @@ "node": ">=10" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.0.0-beta.50", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.50.tgz", @@ -10523,6 +11021,52 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/snake-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", @@ -10595,6 +11139,16 @@ "node": ">=8" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -11548,6 +12102,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index c484784..1cb1158 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,14 @@ "dev": "npm run generate && vite", "build": "npm run generate && tsc -b && vite build", "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", "preview": "vite preview", "generate": "buf generate", "test": "jest", "test:watch": "jest --watch", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "prepare": "husky" }, "dependencies": { "@cormoran/zmk-studio-react-hook": "github:cormoran/react-zmk-studio", @@ -48,10 +51,13 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "husky": "^9.1.7", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^30.2.0", + "lint-staged": "^16.2.7", "postcss": "^8.5.6", + "prettier": "^3.8.1", "run-script-os": "^1.1.6", "tailwindcss": "^4.1.18", "ts-jest": "^29.4.6", @@ -62,6 +68,15 @@ "vite": "npm:rolldown-vite@7.2.5", "vite-plugin-svgr": "^4.5.0" }, + "lint-staged": { + "*.{ts,tsx}": [ + "prettier --write", + "eslint --fix" + ], + "*.{json,md,yml,yaml}": [ + "prettier --write" + ] + }, "pnpm": { "overrides": { "vite": "npm:rolldown-vite@7.2.5" diff --git a/src/components/BatteryHistoryChart.tsx b/src/components/BatteryHistoryChart.tsx index 9afce7b..c1ef64f 100644 --- a/src/components/BatteryHistoryChart.tsx +++ b/src/components/BatteryHistoryChart.tsx @@ -36,7 +36,7 @@ interface RestartMarker { function formatTimestamp(timestamp: number): string { const hours = Math.floor(timestamp / 3600); const minutes = Math.floor((timestamp % 3600) / 60); - + if (hours > 0) { return `${hours}h ${minutes}m`; } @@ -54,7 +54,8 @@ function detectRestarts(devices: DeviceBatteryHistory[]): RestartMarker[] { const currTimestamp = device.entries[i].timestamp; // Detect restart: timestamp goes backwards or resets to a very small value - const isRestart = currTimestamp < prevTimestamp || currTimestamp < ONE_HOUR_IN_SECONDS; + const isRestart = + currTimestamp < prevTimestamp || currTimestamp < ONE_HOUR_IN_SECONDS; if (isRestart && !seenTimestamps.has(currTimestamp)) { restarts.push({ timestamp: currTimestamp }); @@ -95,7 +96,11 @@ function combineDeviceData(devices: DeviceBatteryHistory[]): ChartDataPoint[] { } // Custom tooltip component -function CustomTooltip({ active, payload, label }: { +function CustomTooltip({ + active, + payload, + label, +}: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string; @@ -103,10 +108,13 @@ function CustomTooltip({ active, payload, label }: { if (active && payload && payload.length) { return (
-

{label}

+

+ {label} +

{payload.map((entry, index) => (

- {entry.name}: {entry.value}% + {entry.name}:{" "} + {entry.value}%

))}
@@ -115,7 +123,10 @@ function CustomTooltip({ active, payload, label }: { return null; } -export function BatteryHistoryChart({ devices, deviceColors }: BatteryHistoryChartProps) { +export function BatteryHistoryChart({ + devices, + deviceColors, +}: BatteryHistoryChartProps) { const chartData = useMemo(() => combineDeviceData(devices), [devices]); const restartMarkers = useMemo(() => detectRestarts(devices), [devices]); @@ -176,7 +187,7 @@ export function BatteryHistoryChart({ devices, deviceColors }: BatteryHistoryCha color: "var(--color-text-secondary)", }} /> - + {/* Restart markers */} {restartMarkers.map((marker, index) => ( ); } - diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index 47d32c4..541186d 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -170,7 +170,7 @@ export function SplashScreen({ )} - + {/* Demo mode hint */} - Try{" "} - demo mode without - a device + Try demo mode{" "} + without a device diff --git a/src/components/UnlockPrompt.tsx b/src/components/UnlockPrompt.tsx index 9ce81a1..27b9c7a 100644 --- a/src/components/UnlockPrompt.tsx +++ b/src/components/UnlockPrompt.tsx @@ -36,8 +36,8 @@ export function UnlockPrompt({ open, onClose, onRetry }: UnlockPromptProps) { {/* Description */} - Your keyboard's ZMK Studio is locked. Please unlock it to continue - editing your keymap. + Your keyboard's ZMK Studio is locked. Please unlock it to + continue editing your keymap. {/* Instructions */} diff --git a/src/hooks/__tests__/useBatteryHistory.test.tsx b/src/hooks/__tests__/useBatteryHistory.test.tsx index 9271b33..aa22765 100644 --- a/src/hooks/__tests__/useBatteryHistory.test.tsx +++ b/src/hooks/__tests__/useBatteryHistory.test.tsx @@ -74,12 +74,17 @@ describe("useBatteryHistory", () => { await result.current.loadBatteryHistory(); }); - expect(result.current.error).toBe("Not connected to device or subsystem not found"); + expect(result.current.error).toBe( + "Not connected to device or subsystem not found", + ); }); it("should set error when subsystem not found", async () => { const wrapper = createWrapper({ - state: { connection: { isConnected: true } as never, customSubsystems: [] }, + state: { + connection: { isConnected: true } as never, + customSubsystems: [], + }, findSubsystem: () => null, onNotification: mockOnNotification, }); @@ -90,7 +95,9 @@ describe("useBatteryHistory", () => { await result.current.loadBatteryHistory(); }); - expect(result.current.error).toBe("Not connected to device or subsystem not found"); + expect(result.current.error).toBe( + "Not connected to device or subsystem not found", + ); }); }); @@ -105,7 +112,9 @@ describe("useBatteryHistory", () => { customSubsystems: [], }, findSubsystem: (id: string) => - id === "zmk__battery_history" ? { index: 0, identifier: "zmk__battery_history" } : null, + id === "zmk__battery_history" + ? { index: 0, identifier: "zmk__battery_history" } + : null, onNotification: mockOnNotification, }); @@ -125,7 +134,7 @@ describe("useBatteryHistory", () => { // Mock successful clear response mockCallRPC.mockResolvedValue( - new Uint8Array([18, 2, 8, 10]) // ClearBatteryHistoryResponse with entriesCleared: 10 + new Uint8Array([18, 2, 8, 10]), // ClearBatteryHistoryResponse with entriesCleared: 10 ); const wrapper = createWrapper({ @@ -134,7 +143,9 @@ describe("useBatteryHistory", () => { customSubsystems: [{ index: 0, identifier: "zmk__battery_history" }], }, findSubsystem: (id: string) => - id === "zmk__battery_history" ? { index: 0, identifier: "zmk__battery_history" } : null, + id === "zmk__battery_history" + ? { index: 0, identifier: "zmk__battery_history" } + : null, onNotification: mockOnNotification, }); @@ -160,7 +171,9 @@ describe("useBatteryHistory", () => { customSubsystems: [{ index: 0, identifier: "zmk__battery_history" }], }, findSubsystem: (id: string) => - id === "zmk__battery_history" ? { index: 0, identifier: "zmk__battery_history" } : null, + id === "zmk__battery_history" + ? { index: 0, identifier: "zmk__battery_history" } + : null, onNotification: mockOnNotification, }); diff --git a/src/hooks/__tests__/useKeymap.test.tsx b/src/hooks/__tests__/useKeymap.test.tsx index b03bf2d..e7baf28 100644 --- a/src/hooks/__tests__/useKeymap.test.tsx +++ b/src/hooks/__tests__/useKeymap.test.tsx @@ -75,7 +75,10 @@ const mockKeymap = { const mockBehaviors = [1, 2, 3]; // kp, trans, mo -const mockBehaviorDetails: Record = { +const mockBehaviorDetails: Record< + number, + { id: number; displayName: string; metadata: never[] } +> = { 1: { id: 1, displayName: "kp", metadata: [] }, 2: { id: 2, displayName: "trans", metadata: [] }, 3: { id: 3, displayName: "mo", metadata: [] }, @@ -129,17 +132,23 @@ describe("useKeymap", () => { // Setup mock responses mockCallRpc.mockImplementation(async (_conn, req) => { if (req.keymap?.getPhysicalLayouts) { - return { keymap: { getPhysicalLayouts: mockPhysicalLayouts } } as never; + return { + keymap: { getPhysicalLayouts: mockPhysicalLayouts }, + } as never; } if (req.keymap?.getKeymap) { return { keymap: { getKeymap: mockKeymap } } as never; } if (req.behaviors?.listAllBehaviors) { - return { behaviors: { listAllBehaviors: { behaviors: mockBehaviors } } } as never; + return { + behaviors: { listAllBehaviors: { behaviors: mockBehaviors } }, + } as never; } if (req.behaviors?.getBehaviorDetails) { const id = req.behaviors.getBehaviorDetails.behaviorId; - return { behaviors: { getBehaviorDetails: mockBehaviorDetails[id] } } as never; + return { + behaviors: { getBehaviorDetails: mockBehaviorDetails[id] }, + } as never; } if (req.keymap?.checkUnsavedChanges) { return { keymap: { checkUnsavedChanges: false } } as never; @@ -195,17 +204,23 @@ describe("useKeymap", () => { // Setup mock responses for successful loading mockCallRpc.mockImplementation(async (_conn, req) => { if (req.keymap?.getPhysicalLayouts) { - return { keymap: { getPhysicalLayouts: mockPhysicalLayouts } } as never; + return { + keymap: { getPhysicalLayouts: mockPhysicalLayouts }, + } as never; } if (req.keymap?.getKeymap) { return { keymap: { getKeymap: mockKeymap } } as never; } if (req.behaviors?.listAllBehaviors) { - return { behaviors: { listAllBehaviors: { behaviors: mockBehaviors } } } as never; + return { + behaviors: { listAllBehaviors: { behaviors: mockBehaviors } }, + } as never; } if (req.behaviors?.getBehaviorDetails) { const id = req.behaviors.getBehaviorDetails.behaviorId; - return { behaviors: { getBehaviorDetails: mockBehaviorDetails[id] } } as never; + return { + behaviors: { getBehaviorDetails: mockBehaviorDetails[id] }, + } as never; } if (req.keymap?.checkUnsavedChanges) { return { keymap: { checkUnsavedChanges: false } } as never; @@ -235,17 +250,23 @@ describe("useKeymap", () => { mockCallRpc.mockImplementation(async (_conn, req) => { if (req.keymap?.getPhysicalLayouts) { - return { keymap: { getPhysicalLayouts: mockPhysicalLayouts } } as never; + return { + keymap: { getPhysicalLayouts: mockPhysicalLayouts }, + } as never; } if (req.keymap?.getKeymap) { return { keymap: { getKeymap: mockKeymap } } as never; } if (req.behaviors?.listAllBehaviors) { - return { behaviors: { listAllBehaviors: { behaviors: mockBehaviors } } } as never; + return { + behaviors: { listAllBehaviors: { behaviors: mockBehaviors } }, + } as never; } if (req.behaviors?.getBehaviorDetails) { const id = req.behaviors.getBehaviorDetails.behaviorId; - return { behaviors: { getBehaviorDetails: mockBehaviorDetails[id] } } as never; + return { + behaviors: { getBehaviorDetails: mockBehaviorDetails[id] }, + } as never; } if (req.keymap?.checkUnsavedChanges) { return { keymap: { checkUnsavedChanges: false } } as never; @@ -276,17 +297,23 @@ describe("useKeymap", () => { mockCallRpc.mockImplementation(async (_conn, req) => { if (req.keymap?.getPhysicalLayouts) { - return { keymap: { getPhysicalLayouts: mockPhysicalLayouts } } as never; + return { + keymap: { getPhysicalLayouts: mockPhysicalLayouts }, + } as never; } if (req.keymap?.getKeymap) { return { keymap: { getKeymap: mockKeymap } } as never; } if (req.behaviors?.listAllBehaviors) { - return { behaviors: { listAllBehaviors: { behaviors: mockBehaviors } } } as never; + return { + behaviors: { listAllBehaviors: { behaviors: mockBehaviors } }, + } as never; } if (req.behaviors?.getBehaviorDetails) { const id = req.behaviors.getBehaviorDetails.behaviorId; - return { behaviors: { getBehaviorDetails: mockBehaviorDetails[id] } } as never; + return { + behaviors: { getBehaviorDetails: mockBehaviorDetails[id] }, + } as never; } if (req.keymap?.checkUnsavedChanges) { return { keymap: { checkUnsavedChanges: false } } as never; diff --git a/src/hooks/__tests__/useRuntimeInputProcessor.test.tsx b/src/hooks/__tests__/useRuntimeInputProcessor.test.tsx index 9b34b1d..08da2ad 100644 --- a/src/hooks/__tests__/useRuntimeInputProcessor.test.tsx +++ b/src/hooks/__tests__/useRuntimeInputProcessor.test.tsx @@ -8,7 +8,10 @@ import { renderHook, act } from "@testing-library/react"; import { useRuntimeInputProcessor } from "../useRuntimeInputProcessor"; import { ZMKAppContext } from "@cormoran/zmk-studio-react-hook"; import type { ReactNode } from "react"; -import { Response, Notification } from "../../proto/zmk/runtime_input_processor/runtime_input_processor"; +import { + Response, + Notification, +} from "../../proto/zmk/runtime_input_processor/runtime_input_processor"; // Mock ZMKCustomSubsystem const mockCallRPC = jest.fn(); @@ -53,7 +56,9 @@ describe("useRuntimeInputProcessor", () => { onNotification: mockOnNotification, }); - const { result } = renderHook(() => useRuntimeInputProcessor(), { wrapper }); + const { result } = renderHook(() => useRuntimeInputProcessor(), { + wrapper, + }); expect(result.current.processors).toEqual([]); expect(result.current.isLoading).toBe(false); @@ -69,29 +74,40 @@ describe("useRuntimeInputProcessor", () => { onNotification: mockOnNotification, }); - const { result } = renderHook(() => useRuntimeInputProcessor(), { wrapper }); + const { result } = renderHook(() => useRuntimeInputProcessor(), { + wrapper, + }); await act(async () => { await result.current.loadProcessors(); }); - expect(result.current.error).toBe("Not connected to device or subsystem not found"); + expect(result.current.error).toBe( + "Not connected to device or subsystem not found", + ); }); it("should set error when subsystem not found", async () => { const wrapper = createWrapper({ - state: { connection: { isConnected: true } as never, customSubsystems: [] }, + state: { + connection: { isConnected: true } as never, + customSubsystems: [], + }, findSubsystem: () => null, onNotification: mockOnNotification, }); - const { result } = renderHook(() => useRuntimeInputProcessor(), { wrapper }); + const { result } = renderHook(() => useRuntimeInputProcessor(), { + wrapper, + }); await act(async () => { await result.current.loadProcessors(); }); - expect(result.current.error).toBe("Not connected to device or subsystem not found"); + expect(result.current.error).toBe( + "Not connected to device or subsystem not found", + ); }); }); @@ -100,11 +116,15 @@ describe("useRuntimeInputProcessor", () => { const mockConnection = { isConnected: true }; // Mock notification callback - let notificationCallback: ((notification: { payload: Uint8Array }) => void) | null = null; - mockOnNotification.mockImplementation((subscription: { callback: typeof notificationCallback }) => { - notificationCallback = subscription.callback; - return () => {}; // unsubscribe function - }); + let notificationCallback: + | ((notification: { payload: Uint8Array }) => void) + | null = null; + mockOnNotification.mockImplementation( + (subscription: { callback: typeof notificationCallback }) => { + notificationCallback = subscription.callback; + return () => {}; // unsubscribe function + }, + ); // Mock successful RPC response (empty, data comes via notification) const response = Response.create({ @@ -115,18 +135,24 @@ describe("useRuntimeInputProcessor", () => { const wrapper = createWrapper({ state: { connection: mockConnection as never, - customSubsystems: [{ index: 0, identifier: "zmk__runtime_input_processor" }], + customSubsystems: [ + { index: 0, identifier: "zmk__runtime_input_processor" }, + ], }, findSubsystem: (id: string) => - id === "zmk__runtime_input_processor" ? { index: 0, identifier: "zmk__runtime_input_processor" } : null, + id === "zmk__runtime_input_processor" + ? { index: 0, identifier: "zmk__runtime_input_processor" } + : null, onNotification: mockOnNotification, }); - const { result } = renderHook(() => useRuntimeInputProcessor(), { wrapper }); + const { result } = renderHook(() => useRuntimeInputProcessor(), { + wrapper, + }); // Wait for useEffect to trigger loadProcessors await act(async () => { - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); }); // Simulate notification arrival @@ -142,8 +168,10 @@ describe("useRuntimeInputProcessor", () => { }, }); await act(async () => { - notificationCallback({ payload: Notification.encode(notification).finish() }); - await new Promise(resolve => setTimeout(resolve, 600)); // Wait for notification collection timeout + notificationCallback({ + payload: Notification.encode(notification).finish(), + }); + await new Promise((resolve) => setTimeout(resolve, 600)); // Wait for notification collection timeout }); } @@ -167,11 +195,15 @@ describe("useRuntimeInputProcessor", () => { customSubsystems: [], }, findSubsystem: (id: string) => - id === "zmk__runtime_input_processor" ? { index: 0, identifier: "zmk__runtime_input_processor" } : null, + id === "zmk__runtime_input_processor" + ? { index: 0, identifier: "zmk__runtime_input_processor" } + : null, onNotification: mockOnNotification, }); - const { result } = renderHook(() => useRuntimeInputProcessor(), { wrapper }); + const { result } = renderHook(() => useRuntimeInputProcessor(), { + wrapper, + }); // Verify the functions exist expect(typeof result.current.loadProcessors).toBe("function"); @@ -186,23 +218,27 @@ describe("useRuntimeInputProcessor", () => { it("should set scaling successfully and simplify fraction", async () => { const mockConnection = { isConnected: true }; - let notificationCallback: ((notification: { payload: Uint8Array }) => void) | null = null; - mockOnNotification.mockImplementation((subscription: { callback: typeof notificationCallback }) => { - notificationCallback = subscription.callback; - return () => {}; - }); + let notificationCallback: + | ((notification: { payload: Uint8Array }) => void) + | null = null; + mockOnNotification.mockImplementation( + (subscription: { callback: typeof notificationCallback }) => { + notificationCallback = subscription.callback; + return () => {}; + }, + ); // Mock successful initial load const initialLoadResponse = Response.create({ listProcessors: {} }); - + // Mock successful set scaling response const setScalingResponse = Response.create({ setScaling: { success: true }, }); - + // Mock reload after setting const reloadResponse = Response.create({ listProcessors: {} }); - + mockCallRPC .mockResolvedValueOnce(Response.encode(initialLoadResponse).finish()) .mockResolvedValueOnce(Response.encode(setScalingResponse).finish()) @@ -211,18 +247,24 @@ describe("useRuntimeInputProcessor", () => { const wrapper = createWrapper({ state: { connection: mockConnection as never, - customSubsystems: [{ index: 0, identifier: "zmk__runtime_input_processor" }], + customSubsystems: [ + { index: 0, identifier: "zmk__runtime_input_processor" }, + ], }, findSubsystem: (id: string) => - id === "zmk__runtime_input_processor" ? { index: 0, identifier: "zmk__runtime_input_processor" } : null, + id === "zmk__runtime_input_processor" + ? { index: 0, identifier: "zmk__runtime_input_processor" } + : null, onNotification: mockOnNotification, }); - const { result } = renderHook(() => useRuntimeInputProcessor(), { wrapper }); + const { result } = renderHook(() => useRuntimeInputProcessor(), { + wrapper, + }); // Wait for initial load and send initial notification await act(async () => { - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); if (notificationCallback) { const initialNotification = Notification.create({ processorSettings: { @@ -234,9 +276,11 @@ describe("useRuntimeInputProcessor", () => { }, }, }); - notificationCallback({ payload: Notification.encode(initialNotification).finish() }); + notificationCallback({ + payload: Notification.encode(initialNotification).finish(), + }); } - await new Promise(resolve => setTimeout(resolve, 600)); + await new Promise((resolve) => setTimeout(resolve, 600)); }); // Now call setScaling with a value that can be simplified (200/100 => 2/1) @@ -254,9 +298,11 @@ describe("useRuntimeInputProcessor", () => { }, }, }); - notificationCallback({ payload: Notification.encode(updatedNotification).finish() }); + notificationCallback({ + payload: Notification.encode(updatedNotification).finish(), + }); } - await new Promise(resolve => setTimeout(resolve, 600)); + await new Promise((resolve) => setTimeout(resolve, 600)); }); expect(result.current.error).toBe(null); @@ -269,23 +315,27 @@ describe("useRuntimeInputProcessor", () => { it("should set rotation successfully", async () => { const mockConnection = { isConnected: true }; - let notificationCallback: ((notification: { payload: Uint8Array }) => void) | null = null; - mockOnNotification.mockImplementation((subscription: { callback: typeof notificationCallback }) => { - notificationCallback = subscription.callback; - return () => {}; - }); + let notificationCallback: + | ((notification: { payload: Uint8Array }) => void) + | null = null; + mockOnNotification.mockImplementation( + (subscription: { callback: typeof notificationCallback }) => { + notificationCallback = subscription.callback; + return () => {}; + }, + ); // Mock successful initial load const initialLoadResponse = Response.create({ listProcessors: {} }); - + // Mock successful set rotation response const setRotationResponse = Response.create({ setRotation: { success: true }, }); - + // Mock reload after setting const reloadResponse = Response.create({ listProcessors: {} }); - + mockCallRPC .mockResolvedValueOnce(Response.encode(initialLoadResponse).finish()) .mockResolvedValueOnce(Response.encode(setRotationResponse).finish()) @@ -294,18 +344,24 @@ describe("useRuntimeInputProcessor", () => { const wrapper = createWrapper({ state: { connection: mockConnection as never, - customSubsystems: [{ index: 0, identifier: "zmk__runtime_input_processor" }], + customSubsystems: [ + { index: 0, identifier: "zmk__runtime_input_processor" }, + ], }, findSubsystem: (id: string) => - id === "zmk__runtime_input_processor" ? { index: 0, identifier: "zmk__runtime_input_processor" } : null, + id === "zmk__runtime_input_processor" + ? { index: 0, identifier: "zmk__runtime_input_processor" } + : null, onNotification: mockOnNotification, }); - const { result } = renderHook(() => useRuntimeInputProcessor(), { wrapper }); + const { result } = renderHook(() => useRuntimeInputProcessor(), { + wrapper, + }); // Wait for initial load and send initial notification await act(async () => { - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); if (notificationCallback) { const initialNotification = Notification.create({ processorSettings: { @@ -317,9 +373,11 @@ describe("useRuntimeInputProcessor", () => { }, }, }); - notificationCallback({ payload: Notification.encode(initialNotification).finish() }); + notificationCallback({ + payload: Notification.encode(initialNotification).finish(), + }); } - await new Promise(resolve => setTimeout(resolve, 600)); + await new Promise((resolve) => setTimeout(resolve, 600)); }); // Now call setRotation @@ -337,9 +395,11 @@ describe("useRuntimeInputProcessor", () => { }, }, }); - notificationCallback({ payload: Notification.encode(updatedNotification).finish() }); + notificationCallback({ + payload: Notification.encode(updatedNotification).finish(), + }); } - await new Promise(resolve => setTimeout(resolve, 600)); + await new Promise((resolve) => setTimeout(resolve, 600)); }); expect(result.current.error).toBe(null); @@ -356,14 +416,20 @@ describe("useRuntimeInputProcessor", () => { const wrapper = createWrapper({ state: { connection: mockConnection as never, - customSubsystems: [{ index: 0, identifier: "zmk__runtime_input_processor" }], + customSubsystems: [ + { index: 0, identifier: "zmk__runtime_input_processor" }, + ], }, findSubsystem: (id: string) => - id === "zmk__runtime_input_processor" ? { index: 0, identifier: "zmk__runtime_input_processor" } : null, + id === "zmk__runtime_input_processor" + ? { index: 0, identifier: "zmk__runtime_input_processor" } + : null, onNotification: mockOnNotification, }); - const { result } = renderHook(() => useRuntimeInputProcessor(), { wrapper }); + const { result } = renderHook(() => useRuntimeInputProcessor(), { + wrapper, + }); await act(async () => { await result.current.loadProcessors(); @@ -384,14 +450,20 @@ describe("useRuntimeInputProcessor", () => { const wrapper = createWrapper({ state: { connection: mockConnection as never, - customSubsystems: [{ index: 0, identifier: "zmk__runtime_input_processor" }], + customSubsystems: [ + { index: 0, identifier: "zmk__runtime_input_processor" }, + ], }, findSubsystem: (id: string) => - id === "zmk__runtime_input_processor" ? { index: 0, identifier: "zmk__runtime_input_processor" } : null, + id === "zmk__runtime_input_processor" + ? { index: 0, identifier: "zmk__runtime_input_processor" } + : null, onNotification: mockOnNotification, }); - const { result } = renderHook(() => useRuntimeInputProcessor(), { wrapper }); + const { result } = renderHook(() => useRuntimeInputProcessor(), { + wrapper, + }); await act(async () => { await result.current.loadProcessors(); diff --git a/src/hooks/useBLEProfiles.ts b/src/hooks/useBLEProfiles.ts index f0cef2d..0ce0e36 100644 --- a/src/hooks/useBLEProfiles.ts +++ b/src/hooks/useBLEProfiles.ts @@ -1,5 +1,8 @@ import { useState, useEffect, useCallback, useContext, useMemo } from "react"; -import { ZMKCustomSubsystem, ZMKAppContext } from "@cormoran/zmk-studio-react-hook"; +import { + ZMKCustomSubsystem, + ZMKAppContext, +} from "@cormoran/zmk-studio-react-hook"; import { Request, Response, @@ -39,15 +42,16 @@ export function useBLEProfiles(): UseBLEProfilesReturn { const [maxProfiles, setMaxProfiles] = useState(0); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [outputPriority, setOutputPriorityState] = useState(null); + const [outputPriority, setOutputPriorityState] = + useState(null); // Memoize subsystem to avoid unnecessary re-renders const subsystem = useMemo( () => zmkApp?.findSubsystem(SUBSYSTEM_IDENTIFIER), // eslint-disable-next-line react-hooks/exhaustive-deps - [zmkApp?.state.customSubsystems] + [zmkApp?.state.customSubsystems], ); - + // Extract subsystem index as a stable primitive value for dependencies const subsystemIndex = subsystem?.index; @@ -63,7 +67,7 @@ export function useBLEProfiles(): UseBLEProfilesReturn { try { const service = new ZMKCustomSubsystem( zmkApp.state.connection, - subsystemIndex + subsystemIndex, ); const request = Request.create({ @@ -94,7 +98,7 @@ export function useBLEProfiles(): UseBLEProfilesReturn { } catch (err) { console.error("Failed to load profiles:", err); setError( - `Failed to load profiles: ${err instanceof Error ? err.message : "Unknown error"}` + `Failed to load profiles: ${err instanceof Error ? err.message : "Unknown error"}`, ); } finally { setIsLoading(false); @@ -111,7 +115,7 @@ export function useBLEProfiles(): UseBLEProfilesReturn { try { const service = new ZMKCustomSubsystem( zmkApp.state.connection, - subsystemIndex + subsystemIndex, ); const request = Request.create({ @@ -134,13 +138,13 @@ export function useBLEProfiles(): UseBLEProfilesReturn { } catch (err) { console.error("Failed to switch profile:", err); setError( - `Failed to switch profile: ${err instanceof Error ? err.message : "Unknown error"}` + `Failed to switch profile: ${err instanceof Error ? err.message : "Unknown error"}`, ); } finally { setIsLoading(false); } }, - [zmkApp?.state.connection, subsystemIndex, loadProfiles] + [zmkApp?.state.connection, subsystemIndex, loadProfiles], ); const unpairProfile = useCallback( @@ -153,7 +157,7 @@ export function useBLEProfiles(): UseBLEProfilesReturn { try { const service = new ZMKCustomSubsystem( zmkApp.state.connection, - subsystemIndex + subsystemIndex, ); const request = Request.create({ @@ -176,13 +180,13 @@ export function useBLEProfiles(): UseBLEProfilesReturn { } catch (err) { console.error("Failed to unpair profile:", err); setError( - `Failed to unpair profile: ${err instanceof Error ? err.message : "Unknown error"}` + `Failed to unpair profile: ${err instanceof Error ? err.message : "Unknown error"}`, ); } finally { setIsLoading(false); } }, - [zmkApp?.state.connection, subsystemIndex, loadProfiles] + [zmkApp?.state.connection, subsystemIndex, loadProfiles], ); const setProfileName = useCallback( @@ -195,7 +199,7 @@ export function useBLEProfiles(): UseBLEProfilesReturn { try { const service = new ZMKCustomSubsystem( zmkApp.state.connection, - subsystemIndex + subsystemIndex, ); const request = Request.create({ @@ -218,13 +222,13 @@ export function useBLEProfiles(): UseBLEProfilesReturn { } catch (err) { console.error("Failed to set profile name:", err); setError( - `Failed to set profile name: ${err instanceof Error ? err.message : "Unknown error"}` + `Failed to set profile name: ${err instanceof Error ? err.message : "Unknown error"}`, ); } finally { setIsLoading(false); } }, - [zmkApp?.state.connection, subsystemIndex, loadProfiles] + [zmkApp?.state.connection, subsystemIndex, loadProfiles], ); const getOutputPriority = useCallback(async () => { @@ -239,7 +243,7 @@ export function useBLEProfiles(): UseBLEProfilesReturn { try { const service = new ZMKCustomSubsystem( zmkApp.state.connection, - subsystemIndex + subsystemIndex, ); const request = Request.create({ @@ -260,7 +264,7 @@ export function useBLEProfiles(): UseBLEProfilesReturn { } catch (err) { console.error("Failed to get output priority:", err); setError( - `Failed to get output priority: ${err instanceof Error ? err.message : "Unknown error"}` + `Failed to get output priority: ${err instanceof Error ? err.message : "Unknown error"}`, ); } finally { setIsLoading(false); @@ -277,7 +281,7 @@ export function useBLEProfiles(): UseBLEProfilesReturn { try { const service = new ZMKCustomSubsystem( zmkApp.state.connection, - subsystemIndex + subsystemIndex, ); const request = Request.create({ @@ -300,13 +304,13 @@ export function useBLEProfiles(): UseBLEProfilesReturn { } catch (err) { console.error("Failed to set output priority:", err); setError( - `Failed to set output priority: ${err instanceof Error ? err.message : "Unknown error"}` + `Failed to set output priority: ${err instanceof Error ? err.message : "Unknown error"}`, ); } finally { setIsLoading(false); } }, - [zmkApp?.state.connection, subsystemIndex, getOutputPriority] + [zmkApp?.state.connection, subsystemIndex, getOutputPriority], ); // Load profiles and output priority when connection or subsystem changes diff --git a/src/hooks/useBatteryHistory.ts b/src/hooks/useBatteryHistory.ts index 45109de..295a6ac 100644 --- a/src/hooks/useBatteryHistory.ts +++ b/src/hooks/useBatteryHistory.ts @@ -1,5 +1,8 @@ import { useState, useEffect, useCallback, useContext, useMemo } from "react"; -import { ZMKCustomSubsystem, ZMKAppContext } from "@cormoran/zmk-studio-react-hook"; +import { + ZMKCustomSubsystem, + ZMKAppContext, +} from "@cormoran/zmk-studio-react-hook"; import { Request, Response, @@ -39,7 +42,7 @@ export function useBatteryHistory(): UseBatteryHistoryReturn { const subsystem = useMemo( () => zmkApp?.findSubsystem(SUBSYSTEM_IDENTIFIER), // eslint-disable-next-line react-hooks/exhaustive-deps - [zmkApp?.state.customSubsystems] + [zmkApp?.state.customSubsystems], ); // Extract subsystem index as a stable primitive value for dependencies @@ -57,20 +60,23 @@ export function useBatteryHistory(): UseBatteryHistoryReturn { try { const service = new ZMKCustomSubsystem( zmkApp.state.connection, - subsystemIndex + subsystemIndex, ); // Map to store entries by source ID const deviceMap = new Map(); // Set up notification listener for battery history entries - const notificationHandler = (notification: BatteryHistoryNotification) => { + const notificationHandler = ( + notification: BatteryHistoryNotification, + ) => { try { const { sourceId, entry } = notification; // Initialize device if not exists if (!deviceMap.has(sourceId)) { - const deviceName = sourceId === 0 ? "Central" : `Peripheral ${sourceId}`; + const deviceName = + sourceId === 0 ? "Central" : `Peripheral ${sourceId}`; deviceMap.set(sourceId, { sourceId, deviceName, @@ -102,12 +108,17 @@ export function useBatteryHistory(): UseBatteryHistoryReturn { callback: (customNotification) => { // Decode the payload try { - const notification = Notification.decode(customNotification.payload); + const notification = Notification.decode( + customNotification.payload, + ); if (notification.batteryHistory) { notificationHandler(notification.batteryHistory); } } catch (err) { - console.error("Failed to decode battery history notification:", err); + console.error( + "Failed to decode battery history notification:", + err, + ); } }, }); @@ -136,7 +147,7 @@ export function useBatteryHistory(): UseBatteryHistoryReturn { } catch (err) { console.error("Failed to load battery history:", err); setError( - `Failed to load battery history: ${err instanceof Error ? err.message : "Unknown error"}` + `Failed to load battery history: ${err instanceof Error ? err.message : "Unknown error"}`, ); } finally { setIsLoading(false); @@ -152,7 +163,7 @@ export function useBatteryHistory(): UseBatteryHistoryReturn { try { const service = new ZMKCustomSubsystem( zmkApp.state.connection, - subsystemIndex + subsystemIndex, ); const request = Request.create({ @@ -175,7 +186,7 @@ export function useBatteryHistory(): UseBatteryHistoryReturn { } catch (err) { console.error("Failed to clear battery history:", err); setError( - `Failed to clear battery history: ${err instanceof Error ? err.message : "Unknown error"}` + `Failed to clear battery history: ${err instanceof Error ? err.message : "Unknown error"}`, ); } finally { setIsLoading(false); diff --git a/src/hooks/useRuntimeInputProcessor.ts b/src/hooks/useRuntimeInputProcessor.ts index 013f474..ad02472 100644 --- a/src/hooks/useRuntimeInputProcessor.ts +++ b/src/hooks/useRuntimeInputProcessor.ts @@ -1,5 +1,8 @@ import { useState, useEffect, useCallback, useContext, useMemo } from "react"; -import { ZMKCustomSubsystem, ZMKAppContext } from "@cormoran/zmk-studio-react-hook"; +import { + ZMKCustomSubsystem, + ZMKAppContext, +} from "@cormoran/zmk-studio-react-hook"; import { Request, Response, @@ -27,7 +30,10 @@ function gcd(a: number, b: number): number { } // Helper function to simplify a fraction to lowest terms -function simplifyFraction(multiplier: number, divisor: number): { multiplier: number; divisor: number } { +function simplifyFraction( + multiplier: number, + divisor: number, +): { multiplier: number; divisor: number } { if (divisor === 0) return { multiplier, divisor }; const divisorValue = gcd(multiplier, divisor); return { @@ -48,7 +54,11 @@ export interface UseRuntimeInputProcessorReturn { isLoading: boolean; error: string | null; loadProcessors: () => Promise; - setScaling: (name: string, multiplier: number, divisor: number) => Promise; + setScaling: ( + name: string, + multiplier: number, + divisor: number, + ) => Promise; setRotation: (name: string, degrees: number) => Promise; } @@ -62,7 +72,7 @@ export function useRuntimeInputProcessor(): UseRuntimeInputProcessorReturn { const subsystem = useMemo( () => zmkApp?.findSubsystem(SUBSYSTEM_IDENTIFIER), // eslint-disable-next-line react-hooks/exhaustive-deps - [zmkApp?.state.customSubsystems] + [zmkApp?.state.customSubsystems], ); // Extract subsystem index as a stable primitive value for dependencies @@ -80,7 +90,7 @@ export function useRuntimeInputProcessor(): UseRuntimeInputProcessorReturn { try { const service = new ZMKCustomSubsystem( zmkApp.state.connection, - subsystemIndex + subsystemIndex, ); // Map to store processors by name @@ -110,7 +120,9 @@ export function useRuntimeInputProcessor(): UseRuntimeInputProcessorReturn { callback: (customNotification) => { // Decode the payload try { - const notification = Notification.decode(customNotification.payload); + const notification = Notification.decode( + customNotification.payload, + ); if (notification.processorSettings?.processor) { notificationHandler(notification.processorSettings.processor); } @@ -138,13 +150,15 @@ export function useRuntimeInputProcessor(): UseRuntimeInputProcessorReturn { } } finally { // Wait for all notifications to arrive from devices - await new Promise((resolve) => setTimeout(resolve, NOTIFICATION_COLLECTION_TIMEOUT_MS)); + await new Promise((resolve) => + setTimeout(resolve, NOTIFICATION_COLLECTION_TIMEOUT_MS), + ); unsubscribe(); } } catch (err) { console.error("Failed to load processors:", err); setError( - `Failed to load processors: ${err instanceof Error ? err.message : "Unknown error"}` + `Failed to load processors: ${err instanceof Error ? err.message : "Unknown error"}`, ); } finally { setIsLoading(false); @@ -161,7 +175,7 @@ export function useRuntimeInputProcessor(): UseRuntimeInputProcessorReturn { try { const service = new ZMKCustomSubsystem( zmkApp.state.connection, - subsystemIndex + subsystemIndex, ); // Simplify the fraction to reduce risk of overflow @@ -191,13 +205,13 @@ export function useRuntimeInputProcessor(): UseRuntimeInputProcessorReturn { } catch (err) { console.error("Failed to set scaling:", err); setError( - `Failed to set scaling: ${err instanceof Error ? err.message : "Unknown error"}` + `Failed to set scaling: ${err instanceof Error ? err.message : "Unknown error"}`, ); } finally { setIsLoading(false); } }, - [zmkApp?.state.connection, subsystemIndex, loadProcessors] + [zmkApp?.state.connection, subsystemIndex, loadProcessors], ); const setRotation = useCallback( @@ -210,7 +224,7 @@ export function useRuntimeInputProcessor(): UseRuntimeInputProcessorReturn { try { const service = new ZMKCustomSubsystem( zmkApp.state.connection, - subsystemIndex + subsystemIndex, ); const request = Request.create({ @@ -236,13 +250,13 @@ export function useRuntimeInputProcessor(): UseRuntimeInputProcessorReturn { } catch (err) { console.error("Failed to set rotation:", err); setError( - `Failed to set rotation: ${err instanceof Error ? err.message : "Unknown error"}` + `Failed to set rotation: ${err instanceof Error ? err.message : "Unknown error"}`, ); } finally { setIsLoading(false); } }, - [zmkApp?.state.connection, subsystemIndex, loadProcessors] + [zmkApp?.state.connection, subsystemIndex, loadProcessors], ); // Load processors when connection or subsystem changes diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index ec6e2e8..3bd6704 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -1,5 +1,8 @@ import { useState, useEffect, useCallback, useContext, useMemo } from "react"; -import { ZMKCustomSubsystem, ZMKAppContext } from "@cormoran/zmk-studio-react-hook"; +import { + ZMKCustomSubsystem, + ZMKAppContext, +} from "@cormoran/zmk-studio-react-hook"; import { Request, Response, @@ -39,7 +42,7 @@ export function useSettings(): UseSettingsReturn { const subsystem = useMemo( () => zmkApp?.findSubsystem(SUBSYSTEM_IDENTIFIER), // eslint-disable-next-line react-hooks/exhaustive-deps - [zmkApp?.state.customSubsystems] + [zmkApp?.state.customSubsystems], ); // Extract subsystem index as a stable primitive value for dependencies @@ -57,7 +60,7 @@ export function useSettings(): UseSettingsReturn { try { const service = new ZMKCustomSubsystem( zmkApp.state.connection, - subsystemIndex + subsystemIndex, ); // Map to store settings by source ID @@ -67,8 +70,9 @@ export function useSettings(): UseSettingsReturn { const notificationHandler = (settings: ActivitySettings) => { try { const sourceId = settings.source; - const deviceName = sourceId === 0 ? "Central" : `Peripheral ${sourceId}`; - + const deviceName = + sourceId === 0 ? "Central" : `Peripheral ${sourceId}`; + deviceMap.set(sourceId, { sourceId, deviceName, @@ -79,7 +83,10 @@ export function useSettings(): UseSettingsReturn { // Update state with all collected devices setDevices(Array.from(deviceMap.values())); } catch (err) { - console.error("Failed to process activity settings notification:", err); + console.error( + "Failed to process activity settings notification:", + err, + ); } }; @@ -90,7 +97,9 @@ export function useSettings(): UseSettingsReturn { callback: (customNotification) => { // Decode the payload try { - const notification = Notification.decode(customNotification.payload); + const notification = Notification.decode( + customNotification.payload, + ); if (notification.activitySettings?.settings) { notificationHandler(notification.activitySettings.settings); } @@ -118,65 +127,70 @@ export function useSettings(): UseSettingsReturn { } } finally { // Wait for all notifications to arrive from devices - await new Promise((resolve) => setTimeout(resolve, NOTIFICATION_COLLECTION_TIMEOUT_MS)); + await new Promise((resolve) => + setTimeout(resolve, NOTIFICATION_COLLECTION_TIMEOUT_MS), + ); unsubscribe(); } } catch (err) { console.error("Failed to load settings:", err); setError( - `Failed to load settings: ${err instanceof Error ? err.message : "Unknown error"}` + `Failed to load settings: ${err instanceof Error ? err.message : "Unknown error"}`, ); } finally { setIsLoading(false); } }, [zmkApp, subsystemIndex]); - const setActivitySettings = useCallback(async (idleMs: number, sleepMs: number) => { - if (!zmkApp?.state.connection || subsystemIndex === undefined) { - setError("Not connected to device or subsystem not found"); - return; - } + const setActivitySettings = useCallback( + async (idleMs: number, sleepMs: number) => { + if (!zmkApp?.state.connection || subsystemIndex === undefined) { + setError("Not connected to device or subsystem not found"); + return; + } - setIsLoading(true); - setError(null); + setIsLoading(true); + setError(null); - try { - const service = new ZMKCustomSubsystem( - zmkApp.state.connection, - subsystemIndex - ); + try { + const service = new ZMKCustomSubsystem( + zmkApp.state.connection, + subsystemIndex, + ); - const request = Request.create({ - setActivitySettings: { - settings: { - idleMs, - sleepMs, - source: 0, // Not used for set operation + const request = Request.create({ + setActivitySettings: { + settings: { + idleMs, + sleepMs, + source: 0, // Not used for set operation + }, }, - }, - }); + }); - const payload = Request.encode(request).finish(); - const responsePayload = await service.callRPC(payload); + const payload = Request.encode(request).finish(); + const responsePayload = await service.callRPC(payload); - if (responsePayload) { - const resp = Response.decode(responsePayload); - if (resp.error) { - setError(resp.error.message); - } else if (resp.setActivitySettings?.success) { - // Successfully set, reload settings - await loadAllSettings(); + if (responsePayload) { + const resp = Response.decode(responsePayload); + if (resp.error) { + setError(resp.error.message); + } else if (resp.setActivitySettings?.success) { + // Successfully set, reload settings + await loadAllSettings(); + } } + } catch (err) { + console.error("Failed to set activity settings:", err); + setError( + `Failed to set activity settings: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } finally { + setIsLoading(false); } - } catch (err) { - console.error("Failed to set activity settings:", err); - setError( - `Failed to set activity settings: ${err instanceof Error ? err.message : "Unknown error"}` - ); - } finally { - setIsLoading(false); - } - }, [zmkApp, subsystemIndex, loadAllSettings]); + }, + [zmkApp, subsystemIndex, loadAllSettings], + ); const resetToDefaults = useCallback(async () => { // Reset to ZMK default values: diff --git a/src/index.css b/src/index.css index 238262d..e501f1b 100644 --- a/src/index.css +++ b/src/index.css @@ -60,7 +60,11 @@ } html { - font-family: "Inter", system-ui, -apple-system, sans-serif; + font-family: + "Inter", + system-ui, + -apple-system, + sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -72,7 +76,9 @@ color: var(--color-text); min-height: 100vh; overflow-x: hidden; - transition: background-color 0.3s ease, color 0.3s ease; + transition: + background-color 0.3s ease, + color 0.3s ease; } #root { @@ -108,7 +114,9 @@ border-radius: 0.75rem; border: 1px solid var(--glass-border); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); - transition: background-color 0.3s ease, border-color 0.3s ease; + transition: + background-color 0.3s ease, + border-color 0.3s ease; } :root.light .glass-card { @@ -123,7 +131,9 @@ ); backdrop-filter: blur(12px); border: 1px solid var(--glass-border); - transition: background 0.3s ease, border-color 0.3s ease; + transition: + background 0.3s ease, + border-color 0.3s ease; } .btn-electric { @@ -147,7 +157,9 @@ .btn-electric:hover { background: rgba(0, 212, 255, 0.3); border-color: rgba(0, 212, 255, 0.6); - box-shadow: 0 0 20px rgba(0, 212, 255, 0.4), 0 0 40px rgba(0, 212, 255, 0.2); + box-shadow: + 0 0 20px rgba(0, 212, 255, 0.4), + 0 0 40px rgba(0, 212, 255, 0.2); } :root.light .btn-electric:hover { @@ -187,7 +199,9 @@ .btn-neon:hover { background: rgba(0, 255, 204, 0.3); border-color: rgba(0, 255, 204, 0.6); - box-shadow: 0 0 20px rgba(0, 255, 204, 0.4), 0 0 40px rgba(0, 255, 204, 0.2); + box-shadow: + 0 0 20px rgba(0, 255, 204, 0.4), + 0 0 40px rgba(0, 255, 204, 0.2); } :root.light .btn-neon:hover { @@ -334,19 +348,27 @@ /* Utility classes */ .text-glow-electric { - text-shadow: 0 0 10px rgba(0, 212, 255, 0.5), 0 0 20px rgba(0, 212, 255, 0.3); + text-shadow: + 0 0 10px rgba(0, 212, 255, 0.5), + 0 0 20px rgba(0, 212, 255, 0.3); } :root.light .text-glow-electric { - text-shadow: 0 0 10px rgba(0, 153, 204, 0.3), 0 0 20px rgba(0, 153, 204, 0.15); + text-shadow: + 0 0 10px rgba(0, 153, 204, 0.3), + 0 0 20px rgba(0, 153, 204, 0.15); } .text-glow-neon { - text-shadow: 0 0 10px rgba(0, 255, 204, 0.5), 0 0 20px rgba(0, 255, 204, 0.3); + text-shadow: + 0 0 10px rgba(0, 255, 204, 0.5), + 0 0 20px rgba(0, 255, 204, 0.3); } :root.light .text-glow-neon { - text-shadow: 0 0 10px rgba(0, 184, 148, 0.3), 0 0 20px rgba(0, 184, 148, 0.15); + text-shadow: + 0 0 10px rgba(0, 184, 148, 0.3), + 0 0 20px rgba(0, 184, 148, 0.15); } .bg-gradient-dark { diff --git a/src/lib/transport/__tests__/demo-battery.test.ts b/src/lib/transport/__tests__/demo-battery.test.ts index ea5dfbf..0b5bfe7 100644 --- a/src/lib/transport/__tests__/demo-battery.test.ts +++ b/src/lib/transport/__tests__/demo-battery.test.ts @@ -3,7 +3,10 @@ */ import { BatteryHistoryHandler } from "../demo-battery"; -import { Request, Notification } from "../../../proto/zmk/battery_history/battery_history"; +import { + Request, + Notification, +} from "../../../proto/zmk/battery_history/battery_history"; describe("BatteryHistoryHandler", () => { let handler: BatteryHistoryHandler; @@ -26,7 +29,7 @@ describe("BatteryHistoryHandler", () => { it("should send battery history notifications via callback", (done) => { const notifications: Notification[] = []; - + handler.notify((payload: Uint8Array) => { const notification = Notification.decode(payload); notifications.push(notification); @@ -42,9 +45,11 @@ describe("BatteryHistoryHandler", () => { setTimeout(() => { // Should have notifications for both central and peripheral expect(notifications.length).toBeGreaterThan(0); - + // Check that we have notifications from different sources - const sourceIds = new Set(notifications.map(n => n.batteryHistory?.sourceId)); + const sourceIds = new Set( + notifications.map((n) => n.batteryHistory?.sourceId), + ); expect(sourceIds.has(0)).toBe(true); // Central expect(sourceIds.has(1)).toBe(true); // Peripheral @@ -52,12 +57,20 @@ describe("BatteryHistoryHandler", () => { const firstNotification = notifications[0]; expect(firstNotification.batteryHistory).toBeDefined(); expect(firstNotification.batteryHistory?.entry).toBeDefined(); - expect(firstNotification.batteryHistory?.entry?.timestamp).toBeGreaterThan(0); - expect(firstNotification.batteryHistory?.entry?.batteryLevel).toBeGreaterThanOrEqual(0); - expect(firstNotification.batteryHistory?.entry?.batteryLevel).toBeLessThanOrEqual(100); + expect( + firstNotification.batteryHistory?.entry?.timestamp, + ).toBeGreaterThan(0); + expect( + firstNotification.batteryHistory?.entry?.batteryLevel, + ).toBeGreaterThanOrEqual(0); + expect( + firstNotification.batteryHistory?.entry?.batteryLevel, + ).toBeLessThanOrEqual(100); // Verify isLast flag is set correctly - const lastNotifications = notifications.filter(n => n.batteryHistory?.isLast); + const lastNotifications = notifications.filter( + (n) => n.batteryHistory?.isLast, + ); expect(lastNotifications.length).toBeGreaterThan(0); done(); @@ -93,7 +106,7 @@ describe("BatteryHistoryHandler", () => { describe("notify callback", () => { it("should register notify callback", (done) => { let callbackCalled = false; - + handler.notify(() => { callbackCalled = true; }); diff --git a/src/lib/transport/__tests__/demo-runtime-input-processor.test.ts b/src/lib/transport/__tests__/demo-runtime-input-processor.test.ts index a7ebbcf..0053943 100644 --- a/src/lib/transport/__tests__/demo-runtime-input-processor.test.ts +++ b/src/lib/transport/__tests__/demo-runtime-input-processor.test.ts @@ -3,7 +3,10 @@ */ import { RuntimeInputProcessorHandler } from "../demo-runtime-input-processor"; -import { Request, Notification } from "../../../proto/zmk/runtime_input_processor/runtime_input_processor"; +import { + Request, + Notification, +} from "../../../proto/zmk/runtime_input_processor/runtime_input_processor"; describe("RuntimeInputProcessorHandler", () => { let handler: RuntimeInputProcessorHandler; @@ -28,7 +31,7 @@ describe("RuntimeInputProcessorHandler", () => { it("should send processor notifications via callback", (done) => { const notifications: Notification[] = []; - + handler.notify((payload: Uint8Array) => { const notification = Notification.decode(payload); notifications.push(notification); @@ -44,15 +47,23 @@ describe("RuntimeInputProcessorHandler", () => { setTimeout(() => { // Should have at least one processor notification expect(notifications.length).toBeGreaterThan(0); - + // Verify notification structure const firstNotification = notifications[0]; expect(firstNotification.processorSettings).toBeDefined(); expect(firstNotification.processorSettings?.processor).toBeDefined(); - expect(firstNotification.processorSettings?.processor?.name).toBeDefined(); - expect(firstNotification.processorSettings?.processor?.scaleMultiplier).toBeDefined(); - expect(firstNotification.processorSettings?.processor?.scaleDivisor).toBeDefined(); - expect(firstNotification.processorSettings?.processor?.rotationDegrees).toBeDefined(); + expect( + firstNotification.processorSettings?.processor?.name, + ).toBeDefined(); + expect( + firstNotification.processorSettings?.processor?.scaleMultiplier, + ).toBeDefined(); + expect( + firstNotification.processorSettings?.processor?.scaleDivisor, + ).toBeDefined(); + expect( + firstNotification.processorSettings?.processor?.rotationDegrees, + ).toBeDefined(); done(); }, 300); @@ -108,7 +119,7 @@ describe("RuntimeInputProcessorHandler", () => { it("should update processor scaling and send notification", (done) => { const notifications: Notification[] = []; - + handler.notify((payload: Uint8Array) => { const notification = Notification.decode(payload); notifications.push(notification); @@ -127,9 +138,11 @@ describe("RuntimeInputProcessorHandler", () => { // Wait for notification to be sent setTimeout(() => { expect(notifications.length).toBeGreaterThan(0); - + const notification = notifications[0]; - expect(notification.processorSettings?.processor?.scaleMultiplier).toBe(2); + expect(notification.processorSettings?.processor?.scaleMultiplier).toBe( + 2, + ); expect(notification.processorSettings?.processor?.scaleDivisor).toBe(1); done(); @@ -170,7 +183,7 @@ describe("RuntimeInputProcessorHandler", () => { it("should update processor rotation and send notification", (done) => { const notifications: Notification[] = []; - + handler.notify((payload: Uint8Array) => { const notification = Notification.decode(payload); notifications.push(notification); @@ -188,9 +201,11 @@ describe("RuntimeInputProcessorHandler", () => { // Wait for notification to be sent setTimeout(() => { expect(notifications.length).toBeGreaterThan(0); - + const notification = notifications[0]; - expect(notification.processorSettings?.processor?.rotationDegrees).toBe(90); + expect(notification.processorSettings?.processor?.rotationDegrees).toBe( + 90, + ); done(); }, 200); @@ -225,7 +240,7 @@ describe("RuntimeInputProcessorHandler", () => { describe("notify callback", () => { it("should register notify callback", (done) => { let callbackCalled = false; - + handler.notify(() => { callbackCalled = true; }); diff --git a/src/lib/transport/__tests__/demo.test.ts b/src/lib/transport/__tests__/demo.test.ts index 5655e55..3447790 100644 --- a/src/lib/transport/__tests__/demo.test.ts +++ b/src/lib/transport/__tests__/demo.test.ts @@ -12,7 +12,7 @@ describe("Demo Transport", () => { // Skip stream tests in Node.js environment since TransformStream is browser-only it.skip("should create a valid RpcTransport", async () => { const transport = await connect(); - + expect(transport).toBeDefined(); expect(transport.label).toBe("Demo"); expect(transport.abortController).toBeInstanceOf(AbortController); diff --git a/src/lib/transport/demo-battery.ts b/src/lib/transport/demo-battery.ts index 28ac242..d756894 100644 --- a/src/lib/transport/demo-battery.ts +++ b/src/lib/transport/demo-battery.ts @@ -27,7 +27,7 @@ function generateBatteryHistory( // Different starting battery levels for variety const startLevel = sourceId === 0 ? 85 : 78; - + // Generate entries going back in time for (let i = 0; i < count; i++) { const timestamp = now - (count - i - 1) * intervalSeconds; @@ -37,7 +37,7 @@ function generateBatteryHistory( const variation = Math.sin(i * 0.3) * 2; // Small periodic variation const batteryLevel = Math.max( 20, - Math.min(100, startLevel - i * drainRate + variation) + Math.min(100, startLevel - i * drainRate + variation), ); entries.push({ @@ -62,12 +62,15 @@ export class BatteryHistoryHandler { process(request: Request): Response { if (request.getHistory !== undefined) { // Send battery history via notifications - const sendHistoryForDevice = (sourceId: number, entries: BatteryHistoryEntry[]) => { + const sendHistoryForDevice = ( + sourceId: number, + entries: BatteryHistoryEntry[], + ) => { const totalEntries = entries.length; - + entries.forEach((entry, index) => { const isLast = index === entries.length - 1; - + this.callbacks.forEach((cb) => { cb( Notification.encode({ @@ -78,7 +81,7 @@ export class BatteryHistoryHandler { totalEntries, entryIndex: index, }, - }).finish() + }).finish(), ); }); }); @@ -100,18 +103,18 @@ export class BatteryHistoryHandler { if (request.clearHistory !== undefined) { // Calculate total entries before clearing - const totalEntries = - this.batteryHistory.central.length + + const totalEntries = + this.batteryHistory.central.length + this.batteryHistory.peripheral.length; - + // Clear history and regenerate fresh data this.batteryHistory.central = generateBatteryHistory(0, 48); this.batteryHistory.peripheral = generateBatteryHistory(1, 48); - - return { - clearHistory: { - entriesCleared: totalEntries - } + + return { + clearHistory: { + entriesCleared: totalEntries, + }, }; } diff --git a/src/lib/transport/demo-runtime-input-processor.ts b/src/lib/transport/demo-runtime-input-processor.ts index 09c8c70..a2b65d1 100644 --- a/src/lib/transport/demo-runtime-input-processor.ts +++ b/src/lib/transport/demo-runtime-input-processor.ts @@ -11,7 +11,8 @@ import { type ProcessorInfo, } from "../../proto/zmk/runtime_input_processor/runtime_input_processor"; -export const RUNTIME_INPUT_PROCESSOR_IDENTIFIER = "zmk__runtime_input_processor"; +export const RUNTIME_INPUT_PROCESSOR_IDENTIFIER = + "zmk__runtime_input_processor"; /** * Mock runtime input processor data @@ -32,7 +33,7 @@ const MOCK_PROCESSORS: ProcessorInfo[] = [ export class RuntimeInputProcessorHandler { private callbacks: ((data: Uint8Array) => void)[] = []; private processors: ProcessorInfo[] = JSON.parse( - JSON.stringify(MOCK_PROCESSORS) + JSON.stringify(MOCK_PROCESSORS), ); process(request: Request): Response { @@ -47,7 +48,7 @@ export class RuntimeInputProcessorHandler { processorSettings: { processor, }, - }).finish() + }).finish(), ); }); }); @@ -59,22 +60,22 @@ export class RuntimeInputProcessorHandler { if (request.getProcessor !== undefined) { const { name } = request.getProcessor; const processor = this.processors.find((p) => p.name === name); - + if (processor) { return { getProcessor: { processor } }; } - + return { error: { message: `Processor not found: ${name}` } }; } if (request.setScaling !== undefined) { const { name, scaleMultiplier, scaleDivisor } = request.setScaling; const processor = this.processors.find((p) => p.name === name); - + if (processor) { processor.scaleMultiplier = scaleMultiplier; processor.scaleDivisor = scaleDivisor; - + // Send notification about the update setTimeout(() => { console.log("Demo sending updated processor settings:", processor); @@ -84,24 +85,24 @@ export class RuntimeInputProcessorHandler { processorSettings: { processor, }, - }).finish() + }).finish(), ); }); }, 50); - + return { setScaling: { success: true } }; } - + return { setScaling: { success: false } }; } if (request.setRotation !== undefined) { const { name, rotationDegrees } = request.setRotation; const processor = this.processors.find((p) => p.name === name); - + if (processor) { processor.rotationDegrees = rotationDegrees; - + // Send notification about the update setTimeout(() => { console.log("Demo sending updated processor settings:", processor); @@ -111,14 +112,14 @@ export class RuntimeInputProcessorHandler { processorSettings: { processor, }, - }).finish() + }).finish(), ); }); }, 50); - + return { setRotation: { success: true } }; } - + return { setRotation: { success: false } }; } diff --git a/src/lib/transport/demo.ts b/src/lib/transport/demo.ts index e5173aa..3a73388 100644 --- a/src/lib/transport/demo.ts +++ b/src/lib/transport/demo.ts @@ -13,8 +13,14 @@ import { } from "@zmkfirmware/zmk-studio-ts-client"; import { BLEManagementHandler, BLE_MANAGEMENT_IDENTIFIER } from "./demo-ble"; import { SettingsHandler, SETTINGS_IDENTIFIER } from "./demo-settings"; -import { BatteryHistoryHandler, BATTERY_HISTORY_IDENTIFIER } from "./demo-battery"; -import { RuntimeInputProcessorHandler, RUNTIME_INPUT_PROCESSOR_IDENTIFIER } from "./demo-runtime-input-processor"; +import { + BatteryHistoryHandler, + BATTERY_HISTORY_IDENTIFIER, +} from "./demo-battery"; +import { + RuntimeInputProcessorHandler, + RUNTIME_INPUT_PROCESSOR_IDENTIFIER, +} from "./demo-runtime-input-processor"; import { Request as BLERequest, Response as BLEResponse, @@ -223,12 +229,16 @@ class Keyboard { } catch (e) { console.error("Battery History subsystem error:", e); } - } else if (subsystemIndex === this.RUNTIME_INPUT_PROCESSOR_SUBSYSTEM_INDEX) { + } else if ( + subsystemIndex === this.RUNTIME_INPUT_PROCESSOR_SUBSYSTEM_INDEX + ) { // Runtime Input Processor try { const runtimeReq = RuntimeInputProcessorRequest.decode(data); - const runtimeResp = this.runtimeInputProcessorHandler.process(runtimeReq); - responseData = RuntimeInputProcessorResponse.encode(runtimeResp).finish(); + const runtimeResp = + this.runtimeInputProcessorHandler.process(runtimeReq); + responseData = + RuntimeInputProcessorResponse.encode(runtimeResp).finish(); } catch (e) { console.error("Runtime Input Processor subsystem error:", e); } diff --git a/src/main.tsx b/src/main.tsx index bef5202..eff7ccc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,10 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( , -) +); diff --git a/src/pages/BLEConnectionsPage.tsx b/src/pages/BLEConnectionsPage.tsx index de361b6..eb18a7d 100644 --- a/src/pages/BLEConnectionsPage.tsx +++ b/src/pages/BLEConnectionsPage.tsx @@ -31,8 +31,10 @@ export function BLEConnectionsPage() { const [editingIndex, setEditingIndex] = useState(null); const [editName, setEditName] = useState(""); - const [showOutputPriorityWarning, setShowOutputPriorityWarning] = useState(false); - const [pendingOutputPriority, setPendingOutputPriority] = useState(null); + const [showOutputPriorityWarning, setShowOutputPriorityWarning] = + useState(false); + const [pendingOutputPriority, setPendingOutputPriority] = + useState(null); const startEditing = (index: number, currentName: string) => { setEditingIndex(index); @@ -141,7 +143,11 @@ export function BLEConnectionsPage() { ? "bg-[var(--color-electric)]/20 border border-[var(--color-electric)]/40" : "bg-[var(--color-surface)] border border-[var(--color-border)] hover:border-[var(--color-border-hover)]" }`} - onClick={() => handleOutputPriorityChange(OutputPriority.OUTPUT_PRIORITY_USB)} + onClick={() => + handleOutputPriorityChange( + OutputPriority.OUTPUT_PRIORITY_USB, + ) + } disabled={isLoading} > handleOutputPriorityChange(OutputPriority.OUTPUT_PRIORITY_BLE)} + onClick={() => + handleOutputPriorityChange( + OutputPriority.OUTPUT_PRIORITY_BLE, + ) + } disabled={isLoading} >
- +

Change Output Priority?

- Changing the output priority may disconnect DYA Studio from your keyboard. + Changing the output priority may disconnect DYA Studio from + your keyboard.

You will need to reconnect manually after the change. diff --git a/src/pages/BatteryPage.tsx b/src/pages/BatteryPage.tsx index 9ab2d04..b6b7a9d 100644 --- a/src/pages/BatteryPage.tsx +++ b/src/pages/BatteryPage.tsx @@ -22,9 +22,12 @@ export function BatteryPage() { }); // Format last updated time - const lastUpdated = currentLevels.length > 0 - ? new Date(Math.max(...currentLevels.map(d => d.timestamp)) * 1000).toLocaleTimeString() - : "--:--"; + const lastUpdated = + currentLevels.length > 0 + ? new Date( + Math.max(...currentLevels.map((d) => d.timestamp)) * 1000, + ).toLocaleTimeString() + : "--:--"; // Device colors for chart const deviceColors = [ @@ -57,7 +60,10 @@ export function BatteryPage() { className="btn-ghost flex items-center gap-2" aria-label="Refresh battery history" > - + Refresh

@@ -116,7 +122,7 @@ export function BatteryPage() {

Battery History

- + {isLoading && devices.length === 0 ? (
@@ -126,19 +132,24 @@ export function BatteryPage() { ) : devices.length === 0 ? (
- No battery history available. Connect keyboard to view battery history. + No battery history available. Connect keyboard to view battery + history.
) : ( - + )}
{/* Info Box */}

- Battery history is recorded on the keyboard and shows data from all connected devices. - The timestamp resets when the keyboard restarts, indicated by dashed vertical lines in the chart. + Battery history is recorded on the keyboard and shows data from all + connected devices. The timestamp resets when the keyboard restarts, + indicated by dashed vertical lines in the chart.

diff --git a/src/pages/HealthCheckPage.tsx b/src/pages/HealthCheckPage.tsx index 4a76c6e..503f0ed 100644 --- a/src/pages/HealthCheckPage.tsx +++ b/src/pages/HealthCheckPage.tsx @@ -103,8 +103,8 @@ export function HealthCheckPage() { item.status === "ok" ? "text-[var(--color-neon)]" : item.status === "error" - ? "text-red-500" - : "text-[var(--color-text-muted)]" + ? "text-red-500" + : "text-[var(--color-text-muted)]" }`} > {item.status} diff --git a/src/pages/__tests__/BLEConnectionsPage.test.tsx b/src/pages/__tests__/BLEConnectionsPage.test.tsx index c9e47e4..3fdba97 100644 --- a/src/pages/__tests__/BLEConnectionsPage.test.tsx +++ b/src/pages/__tests__/BLEConnectionsPage.test.tsx @@ -175,14 +175,18 @@ describe("BLEConnectionsPage", () => { it("should show refresh button when profiles exist", () => { renderComponent({ isConnected: true }, { profiles: mockProfiles }); - const refreshButtons = screen.getAllByRole("button", { name: /refresh/i }); + const refreshButtons = screen.getAllByRole("button", { + name: /refresh/i, + }); // Should have two refresh buttons: one for output priority and one for profiles expect(refreshButtons.length).toBeGreaterThanOrEqual(1); }); it("should not show refresh button emoji", () => { renderComponent({ isConnected: true }, { profiles: mockProfiles }); - const refreshButtons = screen.getAllByRole("button", { name: /refresh/i }); + const refreshButtons = screen.getAllByRole("button", { + name: /refresh/i, + }); // Check that none of the refresh buttons contain emoji refreshButtons.forEach((button) => { expect(button.textContent).not.toContain("🔄"); @@ -283,7 +287,9 @@ describe("BLEConnectionsPage", () => { { profiles: mockProfiles, loadProfiles: mockLoadProfiles }, ); - const refreshButton = screen.getByRole("button", { name: "Refresh profiles" }); + const refreshButton = screen.getByRole("button", { + name: "Refresh profiles", + }); fireEvent.click(refreshButton); await waitFor(() => { @@ -395,12 +401,14 @@ describe("BLEConnectionsPage", () => { const unpairButton = screen.getAllByRole("button", { name: /unpair/i, })[0]; - const refreshButtons = screen.getAllByRole("button", { name: /refresh/i }); + const refreshButtons = screen.getAllByRole("button", { + name: /refresh/i, + }); expect(switchButton).toBeDisabled(); expect(unpairButton).toBeDisabled(); // All refresh buttons should be disabled when loading - refreshButtons.forEach(button => { + refreshButtons.forEach((button) => { expect(button).toBeDisabled(); }); }); diff --git a/src/pages/__tests__/BatteryPage.test.tsx b/src/pages/__tests__/BatteryPage.test.tsx index df9806e..bf16220 100644 --- a/src/pages/__tests__/BatteryPage.test.tsx +++ b/src/pages/__tests__/BatteryPage.test.tsx @@ -8,7 +8,9 @@ import { useBatteryHistory } from "../../hooks/useBatteryHistory"; // Mock the useBatteryHistory hook jest.mock("../../hooks/useBatteryHistory"); -const mockUseBatteryHistory = useBatteryHistory as jest.MockedFunction; +const mockUseBatteryHistory = useBatteryHistory as jest.MockedFunction< + typeof useBatteryHistory +>; describe("BatteryPage", () => { beforeEach(() => { @@ -27,7 +29,9 @@ describe("BatteryPage", () => { render(); expect(screen.getByText("Battery Status")).toBeInTheDocument(); - expect(screen.getByText("Monitor battery levels and history")).toBeInTheDocument(); + expect( + screen.getByText("Monitor battery levels and history"), + ).toBeInTheDocument(); }); it("should show placeholder when no devices are connected", () => { @@ -106,7 +110,7 @@ describe("BatteryPage", () => { // Check that device names appear (may appear multiple times - in cards and charts) expect(screen.getAllByText("Central").length).toBeGreaterThan(0); expect(screen.getAllByText("Peripheral 1").length).toBeGreaterThan(0); - + // Check battery percentages expect(screen.getByText("80%")).toBeInTheDocument(); expect(screen.getByText("70%")).toBeInTheDocument(); @@ -114,7 +118,7 @@ describe("BatteryPage", () => { it("should have refresh button", () => { const mockLoadBatteryHistory = jest.fn(); - + mockUseBatteryHistory.mockReturnValue({ devices: [], isLoading: false, @@ -141,10 +145,10 @@ describe("BatteryPage", () => { render(); expect( - screen.getByText(/Battery history is recorded on the keyboard/i) + screen.getByText(/Battery history is recorded on the keyboard/i), ).toBeInTheDocument(); expect( - screen.getByText(/timestamp resets when the keyboard restarts/i) + screen.getByText(/timestamp resets when the keyboard restarts/i), ).toBeInTheDocument(); }); }); diff --git a/src/pages/__tests__/KeymapPage.test.tsx b/src/pages/__tests__/KeymapPage.test.tsx index 1738e4b..5478dfe 100644 --- a/src/pages/__tests__/KeymapPage.test.tsx +++ b/src/pages/__tests__/KeymapPage.test.tsx @@ -99,9 +99,14 @@ describe("KeymapPage", () => { setBinding: jest.fn().mockResolvedValue(true), resetBinding: jest.fn().mockResolvedValue(true), moveLayer: jest.fn().mockResolvedValue(true), - addLayer: jest.fn().mockResolvedValue({ index: 0, layer: { id: 0, name: "New", bindings: [] } }), + addLayer: jest.fn().mockResolvedValue({ + index: 0, + layer: { id: 0, name: "New", bindings: [] }, + }), removeLayer: jest.fn().mockResolvedValue(true), - restoreLayer: jest.fn().mockResolvedValue({ id: 0, name: "Restored", bindings: [] }), + restoreLayer: jest + .fn() + .mockResolvedValue({ id: 0, name: "Restored", bindings: [] }), availableLayers: 4, removedLayerIds: [], saveChanges: jest.fn().mockResolvedValue(true), @@ -133,7 +138,7 @@ describe("KeymapPage", () => { - + , ); }; @@ -142,14 +147,14 @@ describe("KeymapPage", () => { renderComponent(); expect(screen.getByText("Keymap")).toBeInTheDocument(); expect( - screen.getByText("Configure key bindings and layers") + screen.getByText("Configure key bindings and layers"), ).toBeInTheDocument(); }); it("should show connect message when not connected", () => { renderComponent(); expect( - screen.getByText("Connect your keyboard to edit keymaps") + screen.getByText("Connect your keyboard to edit keymaps"), ).toBeInTheDocument(); }); @@ -161,10 +166,7 @@ describe("KeymapPage", () => { describe("Connected State - Loading", () => { it("should show loading state", () => { - renderComponent( - { isConnected: true }, - { isLoading: true, keymap: null } - ); + renderComponent({ isConnected: true }, { isLoading: true, keymap: null }); expect(screen.getByText("Loading keymap data...")).toBeInTheDocument(); }); }); @@ -173,7 +175,11 @@ describe("KeymapPage", () => { it("should show error message", () => { renderComponent( { isConnected: true }, - { error: "Failed to load keymap", keymap: mockKeymap, physicalLayouts: mockPhysicalLayouts } + { + error: "Failed to load keymap", + keymap: mockKeymap, + physicalLayouts: mockPhysicalLayouts, + }, ); expect(screen.getByText("Failed to load keymap")).toBeInTheDocument(); }); @@ -187,7 +193,7 @@ describe("KeymapPage", () => { keymap: mockKeymap, physicalLayouts: mockPhysicalLayouts, behaviors: mockBehaviors, - } + }, ); expect(screen.getByText("Base")).toBeInTheDocument(); @@ -202,7 +208,7 @@ describe("KeymapPage", () => { physicalLayouts: mockPhysicalLayouts, behaviors: mockBehaviors, hasUnsavedChanges: true, - } + }, ); expect(screen.getByText("● Unsaved changes")).toBeInTheDocument(); @@ -215,7 +221,7 @@ describe("KeymapPage", () => { keymap: mockKeymap, physicalLayouts: mockPhysicalLayouts, behaviors: mockBehaviors, - } + }, ); expect(screen.getByText("Save")).toBeInTheDocument(); @@ -230,7 +236,7 @@ describe("KeymapPage", () => { physicalLayouts: mockPhysicalLayouts, behaviors: mockBehaviors, hasUnsavedChanges: false, - } + }, ); const saveButton = screen.getByText("Save").closest("button"); @@ -245,7 +251,7 @@ describe("KeymapPage", () => { physicalLayouts: mockPhysicalLayouts, behaviors: mockBehaviors, hasUnsavedChanges: true, - } + }, ); const saveButton = screen.getByText("Save").closest("button"); @@ -262,7 +268,7 @@ describe("KeymapPage", () => { keymap: mockKeymap, physicalLayouts: mockPhysicalLayouts, behaviors: mockBehaviors, - } + }, ); const lowerTab = screen.getByText("Lower"); @@ -270,7 +276,7 @@ describe("KeymapPage", () => { // The Lower tab should now have the active styling expect(lowerTab.closest("button")).toHaveClass( - "bg-[var(--color-electric)]/20" + "bg-[var(--color-electric)]/20", ); }); }); @@ -283,11 +289,15 @@ describe("KeymapPage", () => { keymap: mockKeymap, physicalLayouts: mockPhysicalLayouts, behaviors: mockBehaviors, - } + }, ); - expect(screen.getByLabelText("Move layer up (higher priority)")).toBeInTheDocument(); - expect(screen.getByLabelText("Move layer down (lower priority)")).toBeInTheDocument(); + expect( + screen.getByLabelText("Move layer up (higher priority)"), + ).toBeInTheDocument(); + expect( + screen.getByLabelText("Move layer down (lower priority)"), + ).toBeInTheDocument(); }); it("should disable move up button for first layer", () => { @@ -297,10 +307,12 @@ describe("KeymapPage", () => { keymap: mockKeymap, physicalLayouts: mockPhysicalLayouts, behaviors: mockBehaviors, - } + }, ); - const moveUpButton = screen.getByLabelText("Move layer up (higher priority)"); + const moveUpButton = screen.getByLabelText( + "Move layer up (higher priority)", + ); expect(moveUpButton).toBeDisabled(); }); @@ -314,10 +326,12 @@ describe("KeymapPage", () => { physicalLayouts: mockPhysicalLayouts, behaviors: mockBehaviors, moveLayer: mockMoveLayer, - } + }, ); - const moveDownButton = screen.getByLabelText("Move layer down (lower priority)"); + const moveDownButton = screen.getByLabelText( + "Move layer down (lower priority)", + ); await user.click(moveDownButton); expect(mockMoveLayer).toHaveBeenCalledWith(0, 1); @@ -330,11 +344,11 @@ describe("KeymapPage", () => { { isConnected: true }, { unlockRequired: false, - } + }, ); expect( - screen.queryByText("Keyboard Unlock Required") + screen.queryByText("Keyboard Unlock Required"), ).not.toBeInTheDocument(); }); @@ -350,11 +364,11 @@ describe("KeymapPage", () => { keymap: mockKeymap, physicalLayouts: mockPhysicalLayouts, behaviors: mockBehaviors, - } + }, ); expect( - screen.getByText(/Click on a key to modify its binding/) + screen.getByText(/Click on a key to modify its binding/), ).toBeInTheDocument(); }); @@ -362,8 +376,8 @@ describe("KeymapPage", () => { renderComponent(); expect( screen.getByText( - "Connect your keyboard to edit keymaps. Click on a key to modify its binding." - ) + "Connect your keyboard to edit keymaps. Click on a key to modify its binding.", + ), ).toBeInTheDocument(); }); }); diff --git a/src/pages/__tests__/TrackballPage.test.tsx b/src/pages/__tests__/TrackballPage.test.tsx index e237a08..00315f2 100644 --- a/src/pages/__tests__/TrackballPage.test.tsx +++ b/src/pages/__tests__/TrackballPage.test.tsx @@ -9,7 +9,10 @@ import { useRuntimeInputProcessor } from "../../hooks/useRuntimeInputProcessor"; // Mock the useRuntimeInputProcessor hook jest.mock("../../hooks/useRuntimeInputProcessor"); -const mockUseRuntimeInputProcessor = useRuntimeInputProcessor as jest.MockedFunction; +const mockUseRuntimeInputProcessor = + useRuntimeInputProcessor as jest.MockedFunction< + typeof useRuntimeInputProcessor + >; describe("TrackballPage", () => { beforeEach(() => { @@ -29,7 +32,11 @@ describe("TrackballPage", () => { render(); expect(screen.getByText("Trackball Settings")).toBeInTheDocument(); - expect(screen.getByText("Adjust sensitivity and behavior via runtime input processor")).toBeInTheDocument(); + expect( + screen.getByText( + "Adjust sensitivity and behavior via runtime input processor", + ), + ).toBeInTheDocument(); }); it("should show loading state when initially loading", () => { @@ -44,7 +51,9 @@ describe("TrackballPage", () => { render(); - expect(screen.getByText("Loading trackball settings...")).toBeInTheDocument(); + expect( + screen.getByText("Loading trackball settings..."), + ).toBeInTheDocument(); }); it("should display error message", () => { @@ -75,7 +84,9 @@ describe("TrackballPage", () => { render(); - expect(screen.getByText(/No runtime input processor found/)).toBeInTheDocument(); + expect( + screen.getByText(/No runtime input processor found/), + ).toBeInTheDocument(); }); it("should display processor information when loaded", () => { @@ -177,7 +188,9 @@ describe("TrackballPage", () => { // Click on 2.0x speed button in the ButtonListSelector const speedButtons = screen.getAllByText("2.0x"); // The button is the one in the ButtonListSelector (not the display value) - const speedButton = speedButtons.find(el => el.tagName === 'SPAN' && el.parentElement?.tagName === 'BUTTON'); + const speedButton = speedButtons.find( + (el) => el.tagName === "SPAN" && el.parentElement?.tagName === "BUTTON", + ); if (speedButton && speedButton.parentElement) { await user.click(speedButton.parentElement); } @@ -188,7 +201,7 @@ describe("TrackballPage", () => { }); expect(mockSetScaling).toHaveBeenCalledWith("trackpad", 200, 100); - + jest.useRealTimers(); }); @@ -218,7 +231,9 @@ describe("TrackballPage", () => { // Click on 90° rotation button in the ButtonListSelector const rotationButtons = screen.getAllByText("90°"); // The button is the one in the ButtonListSelector (not the display value) - const rotationButton = rotationButtons.find(el => el.tagName === 'SPAN' && el.parentElement?.tagName === 'BUTTON'); + const rotationButton = rotationButtons.find( + (el) => el.tagName === "SPAN" && el.parentElement?.tagName === "BUTTON", + ); if (rotationButton && rotationButton.parentElement) { await user.click(rotationButton.parentElement); } @@ -229,7 +244,7 @@ describe("TrackballPage", () => { }); expect(mockSetRotation).toHaveBeenCalledWith("trackpad", 90); - + jest.useRealTimers(); }); @@ -278,6 +293,8 @@ describe("TrackballPage", () => { render(); - expect(screen.getByText(/Runtime input processor allows you to adjust/)).toBeInTheDocument(); + expect( + screen.getByText(/Runtime input processor allows you to adjust/), + ).toBeInTheDocument(); }); });